diff --git a/.changeset/afraid-weeks-sort.md b/.changeset/afraid-weeks-sort.md new file mode 100644 index 0000000000..c5ec608daf --- /dev/null +++ b/.changeset/afraid-weeks-sort.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +k8s plugin now surfaces k8s components with only label selector query annotation. +Previously backstage.io/kubernetes-label-selector catalog entity annotation would only work if you also included backstage.io/kubernetes-id. +But backstage.io/kubernetes-id value was ignored diff --git a/.changeset/blue-lemons-reflect.md b/.changeset/blue-lemons-reflect.md new file mode 100644 index 0000000000..8b2fc144fd --- /dev/null +++ b/.changeset/blue-lemons-reflect.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-github-actions': patch +--- + +Use Breadcrumbs from @backstage/core rather than material-ui diff --git a/.changeset/brown-hotels-study.md b/.changeset/brown-hotels-study.md new file mode 100644 index 0000000000..2b9e6a86b8 --- /dev/null +++ b/.changeset/brown-hotels-study.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Move logic for constructing the template form to the backend, using a new `./parameter-schema` endpoint that returns the form schema for a given template. diff --git a/.changeset/chatty-turkeys-fold.md b/.changeset/chatty-turkeys-fold.md new file mode 100644 index 0000000000..bbc083cd85 --- /dev/null +++ b/.changeset/chatty-turkeys-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Bump `@svgr/webpack` from `5.4.x` to `5.5.x`. diff --git a/.changeset/clean-plums-crash.md b/.changeset/clean-plums-crash.md new file mode 100644 index 0000000000..77cd94f097 --- /dev/null +++ b/.changeset/clean-plums-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Forward authorization header on backend request if present diff --git a/.changeset/curvy-pugs-type.md b/.changeset/curvy-pugs-type.md new file mode 100644 index 0000000000..7e94a5b35f --- /dev/null +++ b/.changeset/curvy-pugs-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +GithubDiscoveryProcessor outputs locations as optional to avoid outputting errors for missing locations (see https://github.com/backstage/backstage/issues/4730). diff --git a/.changeset/empty-windows-nail.md b/.changeset/empty-windows-nail.md new file mode 100644 index 0000000000..0b2eeb08cc --- /dev/null +++ b/.changeset/empty-windows-nail.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Make the external `createComponent` route optional, hiding the "Create Component" button if it isn't bound. diff --git a/.changeset/five-lies-smile.md b/.changeset/five-lies-smile.md deleted file mode 100644 index a35a2ca528..0000000000 --- a/.changeset/five-lies-smile.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-circleci': patch -'@backstage/plugin-jenkins': patch ---- - -Refactor to support ADR004 module exporting. - -For more information, see https://backstage.io/docs/architecture-decisions/adrs-adr004. diff --git a/.changeset/fluffy-elephants-suffer.md b/.changeset/fluffy-elephants-suffer.md new file mode 100644 index 0000000000..cd118829ed --- /dev/null +++ b/.changeset/fluffy-elephants-suffer.md @@ -0,0 +1,7 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Add version `backstage.io/v1beta2` schema for Template entities. diff --git a/.changeset/gentle-bears-fry.md b/.changeset/gentle-bears-fry.md new file mode 100644 index 0000000000..43966311c7 --- /dev/null +++ b/.changeset/gentle-bears-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Collect all available metric types if the number exceeds the default page size of 100. diff --git a/.changeset/honest-moose-eat.md b/.changeset/honest-moose-eat.md new file mode 100644 index 0000000000..be42e9e26b --- /dev/null +++ b/.changeset/honest-moose-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Add optional `logClientErrors` to errorHandler to log 4XX errors diff --git a/.changeset/hungry-frogs-flow.md b/.changeset/hungry-frogs-flow.md new file mode 100644 index 0000000000..91a6ab0892 --- /dev/null +++ b/.changeset/hungry-frogs-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Consider both authentication methods for both `onprem` and `cloud` BitBucket diff --git a/.changeset/itchy-items-serve.md b/.changeset/itchy-items-serve.md new file mode 100644 index 0000000000..df39751e9d --- /dev/null +++ b/.changeset/itchy-items-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Use external url for static docs diff --git a/.changeset/kind-pens-deliver.md b/.changeset/kind-pens-deliver.md new file mode 100644 index 0000000000..e4937cb396 --- /dev/null +++ b/.changeset/kind-pens-deliver.md @@ -0,0 +1,10 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-kafka-backend': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-techdocs-backend': patch +--- + +remove usage of res.send() for res.json() and res.end() to ensure content types are more consistently application/json on backend responses and error cases diff --git a/.changeset/late-singers-speak.md b/.changeset/late-singers-speak.md new file mode 100644 index 0000000000..5e58e09c23 --- /dev/null +++ b/.changeset/late-singers-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Change debug log format to print as color grey diff --git a/.changeset/light-vans-hide.md b/.changeset/light-vans-hide.md new file mode 100644 index 0000000000..ccc1339959 --- /dev/null +++ b/.changeset/light-vans-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Add organization name to the API Explorer page title diff --git a/.changeset/long-bikes-check.md b/.changeset/long-bikes-check.md new file mode 100644 index 0000000000..4fbc28d5aa --- /dev/null +++ b/.changeset/long-bikes-check.md @@ -0,0 +1,23 @@ +--- +'@backstage/create-app': patch +--- + +The api-docs plugin has been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. + +If you want to have a button that links to the scaffolder plugin from the API explorer, apply the following changes to `packages/app/src/App.tsx`: + +```diff ++ import { apiDocsPlugin } from '@backstage/plugin-api-docs'; + import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + + const app = createApp({ + // ... + bindRoutes({ bind }) { ++ bind(apiDocsPlugin.externalRoutes, { ++ createComponent: scaffolderPlugin.routes.root, ++ }); + }, + }); +``` + +If you choose to not bind the routes, the button to create new APIs is not displayed. diff --git a/.changeset/metal-pianos-repeat.md b/.changeset/metal-pianos-repeat.md new file mode 100644 index 0000000000..e24320938a --- /dev/null +++ b/.changeset/metal-pianos-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Support AWS auth provider on kubernetes FE plugin diff --git a/.changeset/new-forks-perform.md b/.changeset/new-forks-perform.md new file mode 100644 index 0000000000..04d6d59be1 --- /dev/null +++ b/.changeset/new-forks-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Use entity relation for the owner of an entity in the catalog entity page header. diff --git a/.changeset/nice-spies-beam.md b/.changeset/nice-spies-beam.md new file mode 100644 index 0000000000..0ac956709a --- /dev/null +++ b/.changeset/nice-spies-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Bump `@testing-library/react` in the plugin template from `^10.4.1` to `^11.2.5`. To apply this to an existing plugin, update the dependency in your `package.json`. diff --git a/.changeset/old-coats-suffer.md b/.changeset/old-coats-suffer.md new file mode 100644 index 0000000000..5938c60b1e --- /dev/null +++ b/.changeset/old-coats-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Add authorization header on techdocs api requests. Breaking change as clients now needs the Identity API. diff --git a/.changeset/perfect-donkeys-hope.md b/.changeset/perfect-donkeys-hope.md deleted file mode 100644 index 80feae11a0..0000000000 --- a/.changeset/perfect-donkeys-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Fix config schema for `.app.listen` diff --git a/.changeset/popular-sheep-act.md b/.changeset/popular-sheep-act.md new file mode 100644 index 0000000000..885d9f0e6b --- /dev/null +++ b/.changeset/popular-sheep-act.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-api': patch +'@backstage/core': patch +--- + +Added support for optional external route references. By setting `optional: true` when creating an `ExternalRouteRef` it is no longer a requirement to bind the route in the app. If the app isn't bound `useRouteRef` will return `undefined`. diff --git a/.changeset/red-taxis-confess.md b/.changeset/red-taxis-confess.md new file mode 100644 index 0000000000..4fa40d8f17 --- /dev/null +++ b/.changeset/red-taxis-confess.md @@ -0,0 +1,23 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +The api-docs plugin has been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the api docs plugin to use the new extension components, as well as bind the external route. + +To bind the external route from the api docs plugin to the scaffolder template index page, make sure you have the appropriate imports and add the following to the `createApp` call: + +```ts +import { apiDocsPlugin } from '@backstage/plugin-api-docs'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + +const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(apiDocsPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, +}); +``` + +If you choose to not bind the routes, the button to create new APIs is not displayed. diff --git a/.changeset/rich-socks-move.md b/.changeset/rich-socks-move.md new file mode 100644 index 0000000000..5aeb3121f1 --- /dev/null +++ b/.changeset/rich-socks-move.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-api': patch +'@backstage/core': patch +--- + +Internal refactor of how component data is access to avoid polluting components and make it possible to bridge across versions. diff --git a/.changeset/shaggy-spiders-shop.md b/.changeset/shaggy-spiders-shop.md new file mode 100644 index 0000000000..ae500f1e81 --- /dev/null +++ b/.changeset/shaggy-spiders-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Display '-' instead of 'undefined' if no code coverage is reported. diff --git a/.changeset/short-geese-double.md b/.changeset/short-geese-double.md new file mode 100644 index 0000000000..1c63321be7 --- /dev/null +++ b/.changeset/short-geese-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +New config command to export the configuration schema. When running backstage-cli with yarn, consider using `yarn --silent backstage-cli config:schema` to get a clean output on `stdout`. diff --git a/.changeset/silly-dolphins-repair.md b/.changeset/silly-dolphins-repair.md new file mode 100644 index 0000000000..43c6aed230 --- /dev/null +++ b/.changeset/silly-dolphins-repair.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Added the color prop to TrendLine from the Sparklines props types to be able to have custom colors. diff --git a/.changeset/silver-balloons-play.md b/.changeset/silver-balloons-play.md new file mode 100644 index 0000000000..24a5c8ac46 --- /dev/null +++ b/.changeset/silver-balloons-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Added a new useSupportConfig hook that reads a new `app.support` config key. Also updated the SupportButton and ErrorPage components to use the new config. diff --git a/.changeset/slimy-singers-return.md b/.changeset/slimy-singers-return.md new file mode 100644 index 0000000000..c5fc60aaa2 --- /dev/null +++ b/.changeset/slimy-singers-return.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fixed file path resolution for templates with a file location diff --git a/.changeset/spotty-cooks-work.md b/.changeset/spotty-cooks-work.md new file mode 100644 index 0000000000..d8845918bf --- /dev/null +++ b/.changeset/spotty-cooks-work.md @@ -0,0 +1,22 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Removed support for deprecated publisher auth configuration within the `scaffolder` configuration block, such as `scaffolder.github.token`. Access should instead be configured through `integrations` configuration. + +For example, replace the following configuration in `app-config.yaml` + +```yaml +scaffolder: + github: + token: my-token +``` + +with + +```yaml +integrations: + github: + - host: github.com + token: my-token +``` diff --git a/.changeset/stale-ducks-sing.md b/.changeset/stale-ducks-sing.md new file mode 100644 index 0000000000..59eb956fd0 --- /dev/null +++ b/.changeset/stale-ducks-sing.md @@ -0,0 +1,56 @@ +--- +'@backstage/create-app': patch +--- + +Migrated away from using deprecated routes and router components at top-level in the app, and instead use routable extension pages. + +To apply this change to an existing app, make the following changes to `packages/app/src/App.tsx`: + +Update imports and remove the usage of the deprecated `app.getRoutes()`. + +```diff +-import { Router as DocsRouter } from '@backstage/plugin-techdocs'; ++import { TechdocsPage } from '@backstage/plugin-techdocs'; + import { CatalogImportPage } 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 { TechRadarPage } from '@backstage/plugin-tech-radar'; ++import { SearchPage } from '@backstage/plugin-search'; ++import { UserSettingsPage } from '@backstage/plugin-user-settings'; ++import { ApiExplorerPage } from '@backstage/plugin-api-docs'; + import { EntityPage } from './components/catalog/EntityPage'; + import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; + + + const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); +-const deprecatedAppRoutes = app.getRoutes(); +``` + +As well as update or add the following routes: + +```diff + } /> +- } /> ++ } /> ++ } /> + } ++ element={} + /> + } /> +- } +- /> +- } /> +- {deprecatedAppRoutes} ++ } /> ++ } /> +``` + +If you have added additional plugins with registered routes or are using `Router` components from other plugins, these should be migrated to use the `*Page` components as well. See [this commit](https://github.com/backstage/backstage/commit/abd655e42d4ed416b70848ffdb1c4b99d189f13b) for more examples of how to migrate. + +For more information and the background to this change, see the [composability system migration docs](https://backstage.io/docs/plugins/composability). diff --git a/.changeset/strange-apes-exercise.md b/.changeset/strange-apes-exercise.md new file mode 100644 index 0000000000..2e5b1b1b9e --- /dev/null +++ b/.changeset/strange-apes-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cloudbuild': patch +--- + +Change terminology for cloudbuild diff --git a/.changeset/sweet-experts-whisper.md b/.changeset/sweet-experts-whisper.md new file mode 100644 index 0000000000..ca2f89ceb7 --- /dev/null +++ b/.changeset/sweet-experts-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Expose a configuration option for the oidc scope diff --git a/.changeset/swift-cows-thank.md b/.changeset/swift-cows-thank.md new file mode 100644 index 0000000000..e4cd562c79 --- /dev/null +++ b/.changeset/swift-cows-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Removed fullScreen property from UnregisterEntity Dialog modal. diff --git a/.changeset/tame-wolves-hunt.md b/.changeset/tame-wolves-hunt.md new file mode 100644 index 0000000000..408316af1b --- /dev/null +++ b/.changeset/tame-wolves-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Use Breadcrumbs from @backstage/core diff --git a/.changeset/three-pumpkins-watch.md b/.changeset/three-pumpkins-watch.md new file mode 100644 index 0000000000..cb059a5381 --- /dev/null +++ b/.changeset/three-pumpkins-watch.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +- Add Jenkins logo to project page. +- Move to new Backstage breadcrumb component. +- Change references of deprecated "Job" terminology to "Project" per [Jenkins Glossary](https://www.jenkins.io/doc/book/glossary/). diff --git a/.changeset/twenty-pandas-crash.md b/.changeset/twenty-pandas-crash.md new file mode 100644 index 0000000000..95ff389406 --- /dev/null +++ b/.changeset/twenty-pandas-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Fixed EntityProvider setting 'loading' bool erroneously to true diff --git a/.changeset/young-pens-argue.md b/.changeset/young-pens-argue.md new file mode 100644 index 0000000000..909bf741fd --- /dev/null +++ b/.changeset/young-pens-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Remove read-store-upload loop when uploading S3 objects for TechDocs diff --git a/.dockerignore b/.dockerignore index 7f7dee96c5..e34ae2c37d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ .git +docs +cypress +microsite node_modules -packages/*/node_modules -plugins/*/node_modules -plugins/*/dist +packages +!packages/backend/dist +plugins diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b3c9bc653d..371fae6e6b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,9 +6,13 @@ * @backstage/maintainers /docs/features/techdocs @backstage/techdocs-core +/docs/features/search @backstage/techdocs-core +/docs/assets/search @backstage/techdocs-core /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios -/plugins/techdocs @backstage/techdocs-core /plugins/search @backstage/techdocs-core +/plugins/techdocs @backstage/techdocs-core /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/codecov.yml b/.github/codecov.yml index d39038615f..a15de165d0 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,5 +1,8 @@ comment: false # Ref: https://docs.codecov.io/docs/pull-request-comments +ignore: + - '**/*.stories.*' + coverage: status: project: diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7f889392ac..e60d9b9528 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,28 +1,118 @@ -abc -andrewthauer Apdex -api Api +Autoscaling +Avro +Balachandran +Bigtable +Billett +Blackbox +Chai +Changesets +Chanwit +Codecov +Codehilite +Config +Discoverability +Dockerfile +Dockerize +Docusaurus +Dominik +Ek +Env +Expedia +Figma +Firekube +Fiverr +Fredrik +Georgoulas +GitHub +GitLab +Grafana +GraphQL +Gustavsson +Hackathons +Henneke +Heroku +Hostname +Iain +Ioannis +JavaScript +Kaewkasi +Knex +Kumar +Lerna +Lundberg +Luxon +Malus +Minikube +Mkdocs +Monorepo +Namespaces +Niklas +OAuth +Okta +Oldsberg +Olle +Onboarding +Patrik +Phoen +Platformize +Pomaceous +Preprarer +Protobuf +Proxying +Raghunandan +Readme +Recharts +Redash +Repo +Rollbar +Rollup +Rosaceae +Routable +Scaffolder +Serverless +Sinon +Sneha +Snyk +Splunk +Spotifiers +Spotify +Superfences +Talkdesk +Telenor +Templater +Templaters +Thauer +Tolerations +Tuite +Voi +WWW +Wealthsimple +Weaveworks +Webpack +Zalando +Zhou +Zolotusky +abc +accessors +adamdmharvey +andrewthauer +api apis args asciidoc async -Avro +autoscaling backrub -Balachandran benjdlambert -Bigtable -Billett -Blackbox bool boolean builtins -Chai changeset changesets -Changesets chanwit -Chanwit +ci cisphobia cissexist classname @@ -30,144 +120,113 @@ cli cloudbuild cncf codeblocks -Codecov codehilite -Codehilite codeowners +composability +composable config -Config configmaps configs const cookiecutter css dariddler +dataflow deadnaming destructured dev +devops devs +dhenneke discoverability -Discoverability dls docgen -Dockerfile -Dockerize dockerode -Docusaurus +dtuite dzolotusky -eg -Ek env -Env +esbuild eslint +etag facto failover -Figma -Firekube freben -Fredrik -github -Github -gitlab -Gitlab -Grafana +gitbeaker graphql graphviz -Gustavsson -Hackathons haproxy -heroku -Heroku horizontalpodautoscalers -Hostname +hotspots +html http https -Iain img incentivised inlined inlinehilite interop -javascript -Javascript jq js json +jsonnet jsx -Kaewkasi -Knex kubectl kubernetes learnings lerna -Lerna +lockfile magiclink mailto maintainership -Malus md microsite middleware minikube -Minikube +misconfiguration +misconfigured misgendering mkdocs -Mkdocs monorepo -Monorepo monorepos msw namespace namespaces -Namespaces namespacing neuro newrelic nginx -Niklas +nodegit nohoist nonces +noop npm +nvarchar nvm -oauth -Oauth +octokit oidc -Okta -Oldsberg onboarding -Onboarding pagerduty -Patrik -Phoen +pageview +parallelization plantuml -Pomaceous postgres +postpack pre prebaked preconfigured -Preprarer -Prerequisities +prepack productional -Protobuf proxying -Proxying pygments pymdownx -Raghunandan rankdir readme -Readme -Recharts -Redash replicasets repo -Repo repos rerender rollbar -Rollbar -Rollup -Rosaceae +routable rst rsync rugvip @@ -175,59 +234,50 @@ ruleset sam scaffolded scaffolder -Scaffolder semlas semver -Serverless -Sinon -smartsymobls -Snyk +sourcemaps sparklines -Spotifiers spotify -Spotify +sqlite squidfunk src +stdout stefanalund +subcomponent +subcomponents subkey subtree superfences -Superfences superset talkdesk -Talkdesk tasklist techdocs templated templater -Templater templaters -Templaters -Thauer -theres +theia toc tolerations -Tolerations toolchain toolsets tooltip tooltips touchpoints +transpilation +transpiled ui +unmanaged +unregister untracked upvote url utils +Valdemar validators -Voi -Wealthsimple -Weaveworks -Webpack +varchar +winston www -WWW xyz yaml -Zalando -Zhou -Zolotusky zoomable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35e2a88ff..2337a70e2c 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 @@ -73,7 +77,7 @@ jobs: run: yarn prettier:check - name: validate config - run: yarn backstage-cli config:check + run: yarn backstage-cli config:check --lax - name: lint run: yarn lerna -- run lint --since origin/master @@ -83,7 +87,7 @@ jobs: - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} - run: yarn lerna -- run build --since origin/master + run: yarn lerna -- run build --since origin/master --include-dependencies - name: build all packages if: ${{ steps.yarn-lock.outcome == 'failure' }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fafd3756d..92660b9ca4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,6 +11,11 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [master] + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#scanning-pull-requests + paths-ignore: + - '**/*.md' + - '**/*.yml' + - '**/*.yaml' schedule: - cron: '0 8 * * 6' @@ -36,11 +41,6 @@ jobs: # a pull request then we can checkout the head. fetch-depth: 2 - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 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..9d054ff6df --- /dev/null +++ b/.github/workflows/fossa.yml @@ -0,0 +1,24 @@ +name: FOSSA +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + analyze: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Install Fossa + run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash" + + - name: Fossa Configure & Analyze + env: + # FOSSA Push-Only API Token + GITHUB_REF: $GITHUB_REF + FOSSA_API_KEY: 9ee7e8893660832a7387dcc32377fb61 + run: node scripts/run-fossa.js diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index c103583052..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 @@ -51,10 +56,13 @@ jobs: - name: test run: yarn lerna -- run test + # credit: https://github.com/appleboy/discord-action/issues/3#issuecomment-731426861 - name: Discord notification if: ${{ failure() }} - uses: Ilshidur/action-discord@0.2.0 env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - with: - args: 'Windows master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' + run: | + $MESSAGE=@" + {\"content\": \"Windows master build failed https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}\"} + "@ + C:\msys64\usr\bin\curl.exe -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST $env:DISCORD_WEBHOOK --data $MESSAGE diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index ca63e627e9..60db9ac16b 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,99 @@ 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 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - 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 --lax + + - name: lint + run: yarn lerna -- run lint + + - name: type checking and declarations + run: yarn tsc:full + + - name: build + run: yarn build + + - name: verify type dependencies + run: yarn lint:type-deps + + - name: test (and upload coverage) + run: | + yarn lerna -- run test -- --coverage + bash <(curl -s https://codecov.io/bash) + # Upload code coverage for some specific flags. Also see .codecov.yml + bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core + bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api + + - name: Discord notification + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.2.0 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + with: + args: 'Master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' + + # A separate release build that is only run for commits that are the result of merging the "Version Packages" PR + # 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: + needs: build + + if: needs.build.outputs.needs_release == 'true' + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + env: + CI: 'true' + NODE_OPTIONS: --max-old-space-size=4096 steps: - uses: actions/checkout@v2 @@ -47,48 +144,36 @@ jobs: run: yarn install --frozen-lockfile # End of yarn setup - - name: validate config - run: yarn backstage-cli config:check - - - name: lint - run: yarn lerna -- run lint - - - name: type checking and declarations + - name: build type declarations run: yarn tsc:full - - name: build - run: yarn build - - - name: verify type dependencies - run: yarn lint:type-deps - - - name: test (and upload coverage) - run: | - yarn lerna -- run test -- --coverage - bash <(curl -s https://codecov.io/bash) - # Upload code coverage for some specific flags. Also see .codecov.yml - bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core - bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api + - name: build packages + run: yarn lerna -- run --ignore example-app build # Publishes current version of packages that are not already present in the registry - name: publish - if: matrix.node-version == '12.x' run: yarn lerna -- publish from-package --yes env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # Tags the commit with the version in the core package if the tag doesn't exist - - uses: Klemensas/action-autotag@1.2.3 - if: matrix.node-version == '12.x' - with: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - package_root: 'packages/core' - tag_prefix: 'v' + # Creates the next available tag with format "release---[.]" + - name: Create a release tag + id: create_tag + run: node scripts/create-release-tag.js + env: + GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + # Convert the newly created tag into a release with changelog information + - name: Create release on GitHub + run: node scripts/create-github-release.js ${{ steps.create_tag.outputs.tag_name }} 1 + env: + GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + + # Notify everyone about this great new release :D - name: Discord notification - if: ${{ failure() }} uses: Ilshidur/action-discord@0.2.0 env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + TAG_NAME: ${{ steps.create_tag.outputs.tag_name }} with: - args: 'Master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' + args: 'A new release has been published! https://github.com/backstage/backstage/releases/tag/{{TAG_NAME}}' diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 8a2fa99ed2..881a7ca5e4 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 @@ -40,3 +43,7 @@ jobs: - name: build microsite run: yarn build working-directory: microsite + + - name: verify sidebars + run: yarn verify:sidebars + working-directory: microsite 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 3f59b815b3..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 @@ -62,7 +63,7 @@ jobs: - name: prepare nightly release run: yarn changeset version --snapshot nightly - # Publishes the nightly release to NPM, by using tag we make sure the release is + # Publishes the nightly release to npm, by using tag we make sure the release is # not flagged as the latest release, which means that people will not get this # version of the package unless requested explicitly - name: publish nightly release diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index a8d476f713..99daba61da 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -1,7 +1,7 @@ name: Automatically add new TechDocs Issues and PRs to the GitHub project board # Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1 # New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board. -# Caveat: New PRs created from forks will not be added since GitHub actions don't share credentials with forks. +# Caveat: New PRs created from forks will not be added since GitHub Actions don't share credentials with forks. on: issues: @@ -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..bb5257b7c6 --- /dev/null +++ b/.github/workflows/tugboat.yml @@ -0,0 +1,130 @@ +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@v2 + - uses: actions/setup-node@v1 + with: + node-version: '14' + + - name: yarn install + run: yarn --cwd cypress install + + # 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: cypress run + uses: cypress-io/github-action@v2 + env: + CYPRESS_baseUrl: ${{steps.get-status-env.outputs.result}} + with: + config-file: ./cypress.json + working-directory: ./cypress + browser: chrome + install: false + headless: true + + - name: update artifact + if: ${{ always() }} + uses: actions/upload-artifact@v1 + with: + name: cypress-videos + path: ./cypress/cypress/videos + + - 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 0ba3e3196b..e1539372ab 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,15 +1,23 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | 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 | +| 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 | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac33ca7f8..9df533bb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,9 @@ # Backstage Changelog -This is a best-effort changelog where we manually collect breaking changes. It is not an exhaustive list of all changes or even features added. +This changelog is no longer being updated and will be removed in the future, as each package now has its own changelog instead. It was a best-effort changelog where we manually collected breaking changes during the `v0.1.1-alpha.` releases. If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! -## Next Release - -> Collect changes for the next release below - ## v0.1.1-alpha.26 ### @backstage/cli diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 55269dd2a5..6990f72c3f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -18,7 +18,7 @@ Harassment includes, but is not limited to: - Offensive comments related to gender, gender identity and expression, sexual orientation, disability, mental illness, neuro(a)typicality, physical appearance, body size, race, age, regional discrimination, political or religious affiliation - Unwelcome comments regarding a person’s lifestyle choices and practices, including those related to food, health, parenting, drugs, and employment - Deliberate misgendering. This includes deadnaming or persistently using a pronoun that does not correctly reflect a person's gender identity. You must address people by the name they give you when not addressing them by their username or handle -- Physical contact and simulated physical contact (eg, textual descriptions like “_hug_” or “_backrub_”) without consent or after a request to stop +- Physical contact and simulated physical contact (e.g., textual descriptions like “_hug_” or “_backrub_”) without consent or after a request to stop - Threats of violence, both physical and psychological - Incitement of violence towards any individual, including encouraging a person to commit suicide or to engage in self-harm - Deliberate intimidation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7157d9f05..56404b2c81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,19 +6,19 @@ Therefore we want to create strong community of contributors -- all working toge Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. ❤️ -Backstage is released under the Apache2.0 License, and original creations contributed to this repo are accepted under the same license. +Backstage is released under the Apache 2.0 License, and original creations contributed to this repo are accepted under the same license. -# Types of Contributions +## Types of Contributions -## Report bugs +### Report bugs No one likes bugs. Report bugs as an issue [here](https://github.com/backstage/backstage/issues/new?template=bug_template.md). -## Fix bugs or build new features +### Fix bugs or build new features Look through the GitHub issues for [bugs](https://github.com/backstage/backstage/labels/bugs), [good first issues](https://github.com/backstage/backstage/labels/good%20first%20issue) or [help wanted](https://github.com/backstage/backstage/labels/help%20wanted). -## Build a plugin +### Build a plugin The value of Backstage grows with every new plugin that gets added. Wouldn't it be fantastic if there was a plugin for every infrastructure project out there? We think so. And we would love your help. @@ -26,27 +26,27 @@ A great reference example of a plugin can be found on [our blog](https://backsta What kind of plugins should/could be created? Some inspiration from the 120+ plugins that we have developed inside Spotify can be found [here](https://backstage.io/demos), but we will keep a running list of suggestions labeled with [[plugin]](https://github.com/backstage/backstage/labels/plugin). -## Suggesting a plugin +### Suggesting a plugin If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. -## Adding Non-code Contributions +### Adding Non-code Contributions Since there is such a large landscape of possible development, build, and deployment environments, we welcome community contributions in these areas in the [`/contrib`](https://github.com/backstage/backstage/tree/master/contrib) folder of the project. This is an excellent place to put things that help out the community at large, but which may not fit within the scope of the core product to support natively. Here, you will find Helm charts, alternative Docker images, and much more. -## Write Documentation +### Write Documentation The current documentation is very limited. Help us make the `/docs` folder come alive. -## Contribute to Storybook +### Contribute to Storybook We think the best way to ensure different plugins provide a consistent experience is through a solid set of reusable UI/UX components. Backstage uses [Storybook](http://backstage.io/storybook). Either help us [create new components](https://github.com/backstage/backstage/labels/help%20wanted) or improve stories for the existing ones (look for files with `*.stories.tsx`). -## Submit Feedback +### Submit Feedback The best way to send feedback is to file [an issue](https://github.com/backstage/backstage/issues). @@ -58,47 +58,75 @@ If you are proposing a feature: - Remember that this is a volunteer-driven project, and that contributions are welcome :) -## Add your company to ADOPTERS +### Add your company to ADOPTERS Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) really helps the project. -# Get Started! +## Get Started! So...feel ready to jump in? Let's do this. 👏🏻💯 -Start by reading our [Getting Started](https://backstage.io/docs/getting-started/) page. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). +Start by reading our [Getting Started for Contributors](https://backstage.io/docs/getting-started/contributors) page to get yourself setup with a fresh copy of Backstage ready for your contributions. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). -# Coding Guidelines +## Coding Guidelines All code is formatted with `prettier` using the configuration in the repo. If possible we recommend configuring your editor to format automatically, but you can also use the `yarn prettier --write ` command to format files. +A consistent coding style is included via [EditorConfig](https://editorconfig.org/) with the file [`.editorconfig`](.editorconfig) at the root of the repo. Depending on your editor of choice, it will either support it out of the box or you can [download a plugin](https://editorconfig.org/#download) for the config to be applied. + If you're contributing to the backend or CLI tooling, be mindful of cross-platform support. [This](https://shapeshed.com/writing-cross-platform-node/) blog post is a good guide of what to keep in mind when writing cross-platform NodeJS. -Also be sure to skim through our [ADRs](https://github.com/backstage/backstage/tree/master/docs/architecture-decisions) to see if they cover what you're working on. In particular [ADR006: Avoid React.FC and React.SFC](https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr006-avoid-react-fc.md) is one to look out for. +Also be sure to skim through our [ADRs](docs/architecture-decisions) to see if they cover what you're working on. In particular [ADR006: Avoid React.FC and React.SFC](docs/architecture-decisions/adr006-avoid-react-fc.md) is one to look out for. If there are any updates in `markdown` file please make sure to run `yarn run lint:docs`. Though it is checked on `lint-staged`. It is required to install [vale](https://docs.errata.ai/vale/install) separately and make sure it is accessed by global command. -# Creating Changesets +## Developer Certificate of Origin -We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. It helps us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy. it helps when contributors include changesets with their pull requests. +As with other CNCF projects, Backstage has adopted a [Developers Certificate of Origin (DCO)](https://developercertificate.org/). A DCO is a lightweight way for a developer to certify that they wrote or otherwise have the right to submit code or documentation to a project. -## To create a changeset +To certify the code you submit to the repository you'll need to add a `Signed-off-by` line to your commits. + +`$ git commit -s -m 'Awesome commit message'` + +Which will look something like the following in the repo; + +``` +Awesome commit message + +Signed-off-by: Jane Smith jane.smith@example.com +``` + +## Creating Changesets + +We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. They help us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy, it helps when contributors include changesets with their pull requests. + +### When to use a changeset? + +Any time a patch, minor, or major change aligning to [Semantic Versioning](https://semver.org) is made to any published package in `packages/` or `plugins/`, a changeset should be used. It helps to align your change to the [Backstage stability index](https://backstage.io/docs/overview/stability-index) for the package you are changing, for example, when to provide additional clarity on deprecation or impacting changes which will then be included into CHANGELOGs. + +In general, changesets are not needed for the documentation, build utilities, contributed samples in `contrib/`, or the [example `packages/app`](packages/app). + +### How to create a changeset 1. Run `yarn changeset` 2. Select which packages you want to include a changeset for -3. Select impact of change that you're introducing (minor, major or patch) +3. Select impact of change that you're introducing (patch, minor, or major) 4. Add generated changeset to Git 5. Push the commit with your changeset to the branch associated with your PR -6. Accept our gratitude for making the release process easier on the maintainer +6. Accept our gratitude for making the release process easier on the maintainers -For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in changesets repository. +For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository. -# Code of Conduct +## Merging to Master + +For those contributors who have earned write access to the repository, when a pull request is approved, in general we prefer the author of the PR to perform the merge themselves. This allows them to own accountability for the change and they likely know best how or when to address pending fixes or additional follow-ups. In this way, we all help contribute to the project's successful outcomes. + +## Code of Conduct This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. [code-of-conduct]: https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md -# Security Issues? +## Security Issues? See [SECURITY](SECURITY.md). diff --git a/DCO b/DCO new file mode 100644 index 0000000000..0cdce0c397 --- /dev/null +++ b/DCO @@ -0,0 +1,37 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. \ No newline at end of file diff --git a/OWNERS.md b/OWNERS.md index 8e5002fc0a..f1649c50b8 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -22,3 +22,6 @@ People that have made significant contributions to the project and earned write - Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) - Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) +- David Tuite - Roadie (GitHub: [dtuite](https://github.com/dtuite)) +- Adam Harvey - DXC Technology (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) +- Dominik Henneke - SDA SE (GitHub: [dhenneke](https://github.com/dhenneke)) diff --git a/README.md b/README.md index a4b21539b5..d01be3f54c 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,9 @@ [![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. So your product teams can ship high-quality code quickly — without compromising autonomy. +[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. Backstage unifies all your infrastructure tooling, services, and documentation to create a streamlined development environment from end to end. @@ -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) @@ -57,10 +53,11 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how - [Adopters](ADOPTERS.md) - Companies already using Backstage - [Blog](https://backstage.io/blog/) - Announcements and updates - [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to our email newsletter +- [Backstage Community Sessions](https://github.com/backstage/community) - Join monthly meetup and explore Backstage community - Give us a star ⭐️ - If you are using Backstage or think it is an interesting project, we would love a star ❤️ ## 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 54d564de7d..f68f5a30a2 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -2,6 +2,19 @@ app: title: Backstage Example App baseUrl: http://localhost:3000 googleAnalyticsTrackingId: # UA-000000-0 + support: + url: https://github.com/backstage/backstage/issues # Used by common ErrorPage + items: # Used by common SupportButton component + - title: Issues + icon: github + links: + - url: https://github.com/backstage/backstage/issues + title: GitHub Issues + - title: Discord Chatroom + icon: chat + links: + - url: https://discord.gg/MUpMjP2 + title: '#backstage' backend: baseUrl: http://localhost:7000 @@ -16,6 +29,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 @@ -46,20 +63,38 @@ proxy: X-Api-Key: $env: NEW_RELIC_REST_API_KEY + '/pagerduty': + target: https://api.pagerduty.com + headers: + Authorization: + $env: PAGERDUTY_TOKEN + '/buildkite/api': target: https://api.buildkite.com/v2/ headers: Authorization: $env: BUILDKITE_TOKEN + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN + organization: name: My Company +# Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' + publisher: + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives. sentry: organization: my-company @@ -78,6 +113,13 @@ kubernetes: - 'config' clusters: [] +kafka: + clientId: backstage + clusters: + - name: cluster + brokers: + - localhost:9092 + integrations: github: - host: github.com @@ -110,7 +152,16 @@ integrations: catalog: rules: - - allow: [Component, API, Group, User, Template, Location] + - allow: + - Component + - API + - Resource + - Group + - User + - Template + - System + - Domain + - Location processors: githubOrg: @@ -120,7 +171,7 @@ catalog: $env: GITHUB_TOKEN #### Example for how to add your GitHub Enterprise instance using the API: # - target: https://ghe.example.net - # apiBaseUrl: https://ghe.example.net/api/v3 + # apiBaseUrl: https://ghe.example.net/api # token: # $env: GHE_TOKEN ldapOrg: @@ -129,7 +180,8 @@ catalog: # - target: ldaps://ds.example.net # bind: # dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net - # secret: { $secret: { env: LDAP_SECRET } } + # secret: + # $env: LDAP_SECRET # users: # dn: ou=people,ou=example,dc=example,dc=net # options: @@ -155,24 +207,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: @@ -184,12 +258,23 @@ scaffolder: baseUrl: https://gitlab.com token: $env: GITLAB_TOKEN + visibility: public # or 'internal' or 'private' azure: baseUrl: https://dev.azure.com/{your-organization} api: token: $env: AZURE_TOKEN + bitbucket: + api: + host: https://bitbucket.org + username: + $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 # session: # secret: custom session secret @@ -237,6 +322,10 @@ auth: $env: AUTH_OAUTH2_AUTH_URL tokenUrl: $env: AUTH_OAUTH2_TOKEN_URL + ### + # provide a list of scopes as needed for your OAuth2 Server: + # + # scope: saml-login-selector openid profile email oidc: development: metadataUrl: @@ -309,3 +398,5 @@ homepage: timezone: 'Europe/Stockholm' - label: TYO timezone: 'Asia/Tokyo' +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' diff --git a/catalog-info.yaml b/catalog-info.yaml index 617d01093e..0cab0c558a 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -4,9 +4,18 @@ metadata: name: backstage description: | Backstage is an open-source developer portal that puts the developer experience first. + links: + - title: Website + url: http://backstage.io + - title: Documentation + url: https://backstage.io/docs + - title: Storybook + url: https://backstage.io/storybook + - title: Discord Chat + url: https://discord.com/invite/EBHEGzX 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/contrib/chart/backstage/templates/backend-secret.yaml b/contrib/chart/backstage/templates/backend-secret.yaml index b340f39d7c..299d893ec4 100644 --- a/contrib/chart/backstage/templates/backend-secret.yaml +++ b/contrib/chart/backstage/templates/backend-secret.yaml @@ -20,4 +20,5 @@ stringData: AZURE_TOKEN: {{ .Values.auth.azure.api.token }} NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }} TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }} + PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }} {{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index a4a0fadcc2..7926ebbc0a 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -127,88 +127,67 @@ appConfig: appOrigin: 'http://localhost:3000/' secure: false clientId: - $secret: - env: AUTH_GOOGLE_CLIENT_ID + $env: AUTH_GOOGLE_CLIENT_ID clientSecret: - $secret: - env: AUTH_GOOGLE_CLIENT_SECRET + $env: AUTH_GOOGLE_CLIENT_SECRET github: development: appOrigin: 'http://localhost:3000/' secure: false clientId: - $secret: - env: AUTH_GITHUB_CLIENT_ID + $env: AUTH_GITHUB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITHUB_CLIENT_SECRET + $env: AUTH_GITHUB_CLIENT_SECRET enterpriseInstanceUrl: - $secret: - env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: appOrigin: 'http://localhost:3000/' secure: false clientId: - $secret: - env: AUTH_GITLAB_CLIENT_ID + $env: AUTH_GITLAB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITLAB_CLIENT_SECRET + $env: AUTH_GITLAB_CLIENT_SECRET audience: - $secret: - env: GITLAB_BASE_URL + $env: GITLAB_BASE_URL okta: development: appOrigin: 'http://localhost:3000/' secure: false clientId: - $secret: - env: AUTH_OKTA_CLIENT_ID + $env: AUTH_OKTA_CLIENT_ID clientSecret: - $secret: - env: AUTH_OKTA_CLIENT_SECRET + $env: AUTH_OKTA_CLIENT_SECRET audience: - $secret: - env: AUTH_OKTA_AUDIENCE + $env: AUTH_OKTA_AUDIENCE oauth2: development: appOrigin: 'http://localhost:3000/' secure: false clientId: - $secret: - env: AUTH_OAUTH2_CLIENT_ID + $env: AUTH_OAUTH2_CLIENT_ID clientSecret: - $secret: - env: AUTH_OAUTH2_CLIENT_SECRET + $env: AUTH_OAUTH2_CLIENT_SECRET authorizationURL: - $secret: - env: AUTH_OAUTH2_AUTH_URL + $env: AUTH_OAUTH2_AUTH_URL tokenURL: - $secret: - env: AUTH_OAUTH2_TOKEN_URL + $env: AUTH_OAUTH2_TOKEN_URL auth0: development: clientId: - $secret: - env: AUTH_AUTH0_CLIENT_ID + $env: AUTH_AUTH0_CLIENT_ID clientSecret: - $secret: - env: AUTH_AUTH0_CLIENT_SECRET + $env: AUTH_AUTH0_CLIENT_SECRET domain: - $secret: - env: AUTH_AUTH0_DOMAIN + $env: AUTH_AUTH0_DOMAIN microsoft: development: clientId: - $secret: - env: AUTH_MICROSOFT_CLIENT_ID + $env: AUTH_MICROSOFT_CLIENT_ID clientSecret: - $secret: - env: AUTH_MICROSOFT_CLIENT_SECRET + $env: AUTH_MICROSOFT_CLIENT_SECRET tenantId: - $secret: - env: AUTH_MICROSOFT_TENANT_ID + $env: AUTH_MICROSOFT_TENANT_ID auth: google: @@ -250,3 +229,4 @@ auth: gitlabToken: g newRelicRestApiKey: r travisciAuthToken: fake-travis-ci-auth-token + pagerdutyToken: h 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..4f4547a49e --- /dev/null +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -0,0 +1,93 @@ +# 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 cookieParser from 'cookie-parser'; +import { Request, Response, NextFunction } from 'express'; +import { JWT } from 'jose'; +import { URL } from 'url'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; + +// ... + +function setTokenCookie( + res: Response, + options: { token: string; secure: boolean; cookieDomain: string }, +) { + try { + const payload = JWT.decode(options.token) as object & { + exp: number; + }; + res.cookie(`token`, options.token, { + expires: new Date(payload?.exp ? payload?.exp * 1000 : 0), + secure: options.secure, + sameSite: 'lax', + domain: options.cookieDomain, + path: '/', + httpOnly: true, + }); + } catch (_err) { + // Ignore + } +} + +async function main() { + // ... + + const discovery = SingleHostDiscovery.fromConfig(config); + const identity = new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + const baseUrl = config.getString('backend.baseUrl'); + const secure = baseUrl.startsWith('https://'); + const cookieDomain = new URL(baseUrl).hostname; + const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const token = + IdentityClient.getBearerToken(req.headers.authorization) || + req.cookies['token']; + req.user = await identity.authenticate(token); + if (!req.headers.authorization) { + // Authorization header may be forwarded by plugin requests + req.headers.authorization = `Bearer ${token}`; + } + if (token !== req.cookies['token']) { + setTokenCookie(res, { + token, + secure, + cookieDomain, + }); + } + next(); + } catch (error) { + res.status(401).send(`Unauthorized`); + } + }; + + const apiRouter = Router(); + apiRouter.use(cookieParser()); + // 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/help-im-behind-a-corporate-proxy.md b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md index f5288cf144..bee0a84fe6 100644 --- a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md +++ b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md @@ -4,6 +4,8 @@ Let's admit it, we've all been there. Sometimes you've gotta run stuff with no w Whilst this isn't supported natively by Backstage, this might help you get your installation up and running making calls through the said proxy tunnel. +## backend + Unfortunately, `nodejs` does not respect `HTTP(S)_PROXY` environment variables by default, and the library that we use to provide `fetch` functionality `node-fetch` (provided by `cross-fetch`) does not also respect these environment variables. There are however some ways to get this to work without too much effort. It's most likely that you're going to run into these issues from the `backend` part of `backstage` as that's the part that isn't helped by your browser or OS's settings for the corporate proxy. @@ -56,3 +58,28 @@ if (process.env.HTTPS_PROXY) { ``` 4. Start the backend with `yarn start` + +## config + +If your development environment is in the cloud (like with [AWS Cloud9](https://aws.amazon.com/cloud9/) or an instance of [Theia](https://theia-ide.org/)), you will need to update your configuration. + +You will probably need to make some changes in `app-config.yaml` (or another config file like `app-config.local.yaml` if you've created it, see the [configuration doc](https://backstage.io/docs/conf/#supplying-configuration)). +The exact values will depend on your setup but for instance, if your public url is `https://your-public-url.com` and the port `3000` and `8080` are open: + +```yaml +app: + baseUrl: https://your-public-url.com:3000 + listen: + host: 0.0.0.0 # This makes the dev server bind to all IPv4 interfaces and not just the baseUrl hostname + +backend: + baseUrl: https://your-public-url.com:8080 + listen: + port: 8080 + cors: + origin: https://your-public-url.com:3000 +``` + +If the protocol is `http`, you will need to set `backend.csp.upgrade-insecure-requests` to `false` as well. + +The app port must proxy web socket connections in order to make hot reloading work. diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 5d633544e3..77b820d921 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -3,7 +3,7 @@ ExampleComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Typography, Grid } from '@material-ui/core'; import { InfoCard, @@ -14,11 +14,11 @@ import { HeaderLabel, SupportButton, identityApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import ExampleFetchComponent from '../ExampleFetchComponent'; -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { const identityApi = useApi(identityApiRef); const userId = identityApi.getUserId(); const profile = identityApi.getProfile(); diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 08d14e8c4c..6992d05866 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -3,7 +3,7 @@ ExampleFetchComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -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 = `{ @@ -57,7 +57,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, @@ -76,7 +76,7 @@ export const DenseTable: FC = ({ viewer }) => { ); }; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { const auth = useApi(githubAuthApiRef); const { value, loading, error } = useAsync(async (): Promise => { diff --git a/contrib/kubernetes/plain_single_backend_deployment/README.md b/contrib/kubernetes/plain_single_backend_deployment/README.md index 48c3490f81..e4709615ce 100644 --- a/contrib/kubernetes/plain_single_backend_deployment/README.md +++ b/contrib/kubernetes/plain_single_backend_deployment/README.md @@ -6,6 +6,8 @@ This directory contains an example of a simple Kubernetes deployment of Backstag You can try the deployment out as is. The easiest way is to use [Docker Desktop](https://www.docker.com/products/docker-desktop) with [Kubernetes](https://docs.docker.com/get-started/kube-deploy/). +You can now follow the documentation here to build the Backend Container [Docker Build](https://backstage.io/docs/getting-started/deployment-docker) + From a fresh clone of this repo, run the following in the root: ```bash diff --git a/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml b/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml index 25c9207438..822f5e42dc 100644 --- a/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml +++ b/contrib/kubernetes/plain_single_backend_deployment/deployment.yaml @@ -53,7 +53,7 @@ spec: volumeMounts: - name: config-volume - mountPath: /usr/src/app/k8s-config.yaml + mountPath: /app/k8s-config.yaml subPath: k8s-config.yaml resources: diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js new file mode 100644 index 0000000000..d7498649a0 --- /dev/null +++ b/cypress/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + rules: { + 'no-console': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: false, + }, + ], + 'jest/expect-expect': 'off', + 'no-restricted-syntax': 'off', + }, +}; diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 0000000000..2bb310e300 --- /dev/null +++ b/cypress/README.md @@ -0,0 +1,27 @@ +# Cypress Tests for Backstage + +Hey 👋 Welcome to the Cypress tests for Backstage. They're designed to be run against the `packages/app` folder in the main repo, and be some form of smoke tests to make sure that we don't break any core functionality. + +They run part of the PR build, and are triggered from the `.github/workflows/tugboat.yml` file. + +The main app gets built up part of a [Tugboat Build](https://tugboat.qa), which when complete, sends a `deployment event` to the PR triggering the aforementioned workflow. + +### Running Locally + +In order to make typescript happy, this `cypress` package is separate from all the Jest dependencies in the monorepo workspaces setup. + +You can run the e2e tests locally pointing at your local running version like so: + +```sh +cd cypress +yarn install +yarn cypress run +``` + +You can open up the `cypress` console by using `yarn cypress open`. + +You can also run towards any Backstage installation by using the Cypress Environment Variable overrides. + +```sh +CYPRESS_baseUrl="http://demo.backstage.io" yarn cypress run +``` diff --git a/cypress/cypress.json b/cypress/cypress.json new file mode 100644 index 0000000000..ebbbe59901 --- /dev/null +++ b/cypress/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:7000", + "integrationFolder": "./src/integration", + "supportFile": "./src/support", + "fixturesFolder": "./src/fixures", + "pluginsFile": "./src/plugins", + "defaultCommandTimeout": 10000 +} diff --git a/cypress/package.json b/cypress/package.json new file mode 100644 index 0000000000..8549386f7e --- /dev/null +++ b/cypress/package.json @@ -0,0 +1,11 @@ +{ + "name": "@backstage/cypress-tests", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "private": true, + "dependencies": { + "cypress": "^6.4.0", + "typescript": "^4.1.3" + } +} diff --git a/cypress/src/fixtures/.gitkeep b/cypress/src/fixtures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/sentry-backend/src/service/sentry-api.test.ts b/cypress/src/integration/catalog.ts similarity index 63% rename from plugins/sentry-backend/src/service/sentry-api.test.ts rename to cypress/src/integration/catalog.ts index f15692861f..31d39e7efd 100644 --- a/plugins/sentry-backend/src/service/sentry-api.test.ts +++ b/cypress/src/integration/catalog.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. @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getRequestHeaders } from './sentry-api'; +/// +import 'os'; -describe('SentryApiForwarder', () => { - it('should generate headers based on token passed in constructor', () => { - expect(getRequestHeaders('testtoken')).toEqual({ - headers: { - Authorization: `Bearer testtoken`, - }, +describe('Catalog', () => { + describe('default entities', () => { + it('displays the correct amount of entities from default config', () => { + cy.loginAsGuest(); + + cy.visit('/catalog'); + + cy.contains('Owned (7)').should('be.visible'); }); }); }); diff --git a/cypress/src/plugins/index.ts b/cypress/src/plugins/index.ts new file mode 100644 index 0000000000..b90c276d71 --- /dev/null +++ b/cypress/src/plugins/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 default () => {}; diff --git a/cypress/src/support/index.ts b/cypress/src/support/index.ts new file mode 100644 index 0000000000..e17081831e --- /dev/null +++ b/cypress/src/support/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ +/// + +Cypress.Commands.add('loginAsGuest', () => { + cy.visit('/', { + onLoad: (win: Window) => + win.localStorage.setItem('@backstage/core:SignInPage:provider', 'guest'), + }); +}); + +export {}; diff --git a/cypress/src/types.d.ts b/cypress/src/types.d.ts new file mode 100644 index 0000000000..361aaba9f3 --- /dev/null +++ b/cypress/src/types.d.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ +declare module 'zombie'; +declare module 'pgtools'; +declare namespace Cypress { + interface Chainable { + /** + * Login as guest + * @example cy.loginAsGuests + */ + loginAsGuest(): Chainable; + } +} diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000..d9b4869ecd --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "declarationMap": false, + "esModuleInterop": true, + "experimentalDecorators": false, + "forceConsistentCasingInFileNames": true, + "importHelpers": false, + "incremental": true, + "isolatedModules": true, + "jsx": "react", + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"], + "module": "ESNext", + "moduleResolution": "node", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "pretty": true, + "removeComments": false, + "resolveJsonModule": true, + "sourceMap": false, + "skipLibCheck": false, + "strict": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2019", + "types": ["node", "cypress"] + } +} diff --git a/cypress/yarn.lock b/cypress/yarn.lock new file mode 100644 index 0000000000..3c737f6ebc --- /dev/null +++ b/cypress/yarn.lock @@ -0,0 +1,1409 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cypress/listr-verbose-renderer@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a" + integrity sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo= + dependencies: + chalk "^1.1.3" + cli-cursor "^1.0.2" + date-fns "^1.27.2" + figures "^1.7.0" + +"@cypress/request@^2.88.5": + version "2.88.5" + resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7" + integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +"@cypress/xvfb@^1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz#2daf42e8275b39f4aa53c14214e557bd14e7748a" + integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q== + dependencies: + debug "^3.1.0" + lodash.once "^4.1.1" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.1" + resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" + integrity sha512-c/qwwcHyafOQuVQJj0IlBjf5yYgBI7YPJ77k4fOJYesb41jio65eaJODRUmfYKhTOFBrIZ66kgvGPlNbjuoRdQ== + dependencies: + any-observable "^0.3.0" + +"@types/sinonjs__fake-timers@^6.0.1": + version "6.0.2" + resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" + integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== + +"@types/sizzle@^2.3.2": + version "2.3.2" + resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" + integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== + +ajv@^6.12.3: + 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" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + +arch@^2.1.2: + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +async@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +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.8.0: + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +blob-util@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" + integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +cachedir@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chalk@^1.0.0, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.4.1: + version "2.4.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-more-types@^2.24.0: + version "2.24.0" + resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" + integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cli-cursor@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" + integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc= + dependencies: + restore-cursor "^1.0.1" + +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-table3@~0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" + integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== + dependencies: + object-assign "^4.1.0" + string-width "^4.2.0" + optionalDependencies: + colors "^1.1.2" + +cli-truncate@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" + integrity sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ= + dependencies: + slice-ansi "0.0.4" + string-width "^1.0.1" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colors@^1.1.2: + version "1.4.0" + resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + +common-tags@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" + integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.6.2: + version "1.6.2" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^7.0.0: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cypress@^6.4.0: + version "6.4.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2" + integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg== + dependencies: + "@cypress/listr-verbose-renderer" "^0.4.1" + "@cypress/request" "^2.88.5" + "@cypress/xvfb" "^1.2.4" + "@types/sinonjs__fake-timers" "^6.0.1" + "@types/sizzle" "^2.3.2" + arch "^2.1.2" + blob-util "2.0.2" + bluebird "^3.7.2" + cachedir "^2.3.0" + chalk "^4.1.0" + check-more-types "^2.24.0" + cli-table3 "~0.6.0" + commander "^5.1.0" + common-tags "^1.8.0" + dayjs "^1.9.3" + debug "^4.1.1" + eventemitter2 "^6.4.2" + execa "^4.0.2" + executable "^4.1.1" + extract-zip "^1.7.0" + fs-extra "^9.0.1" + getos "^3.2.1" + is-ci "^2.0.0" + is-installed-globally "^0.3.2" + lazy-ass "^1.6.0" + listr "^0.14.3" + lodash "^4.17.19" + log-symbols "^4.0.0" + minimist "^1.2.5" + moment "^2.29.1" + ospath "^1.2.2" + pretty-bytes "^5.4.1" + ramda "~0.26.1" + request-progress "^3.0.0" + supports-color "^7.2.0" + tmp "~0.2.1" + untildify "^4.0.0" + url "^0.11.0" + yauzl "^2.10.0" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +date-fns@^1.27.2: + version "1.30.1" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" + integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== + +dayjs@^1.9.3: + version "1.10.4" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" + integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== + +debug@^2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^3.1.0: + 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" + +debug@^4.1.1: + version "4.3.1" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +elegant-spinner@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" + integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +eventemitter2@^6.4.2: + version "6.4.3" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" + integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== + +execa@^4.0.2: + version "4.1.0" + resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +executable@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" + integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg== + dependencies: + pify "^2.2.0" + +exit-hook@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" + integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extract-zip@^1.7.0: + version "1.7.0" + resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" + integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== + dependencies: + concat-stream "^1.6.2" + debug "^2.6.9" + mkdirp "^0.5.4" + yauzl "^2.10.0" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= + dependencies: + pend "~1.2.0" + +figures@^1.7.0: + version "1.7.0" + resolved "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" + integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= + dependencies: + escape-string-regexp "^1.0.5" + object-assign "^4.1.0" + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fs-extra@^9.0.1: + version "9.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +getos@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" + integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== + dependencies: + async "^3.2.0" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob@^7.1.3: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-dirs@^2.0.1: + version "2.1.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" + integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== + dependencies: + ini "1.3.7" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.5" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz#bc18864a6c9fc7b303f2e2abdb9155ad178fbe29" + integrity sha512-kBBSQbz2K0Nyn+31j/w36fUfxkBW9/gfwRWdUY1ULReH3iokVJgddZAFcD1D0xlgTmFxJCbUkUclAlc6/IDJkw== + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + +indent-string@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@1.3.7: + version "1.3.7" + resolved "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-installed-globally@^0.3.2: + version "0.3.2" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + +is-path-inside@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" + integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== + +is-promise@^2.1.0: + version "2.2.2" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +json-schema-traverse@^0.4.1: + version "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@0.2.3: + version "0.2.3" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +lazy-ass@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" + integrity sha1-eZllXoZGwX8In90YfRUNMyTVRRM= + +listr-silent-renderer@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" + integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= + +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + elegant-spinner "^1.0.1" + figures "^1.7.0" + indent-string "^3.0.0" + log-symbols "^1.0.2" + log-update "^2.3.0" + strip-ansi "^3.0.1" + +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== + dependencies: + chalk "^2.4.1" + cli-cursor "^2.1.0" + date-fns "^1.27.2" + figures "^2.0.0" + +listr@^0.14.3: + version "0.14.3" + resolved "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== + dependencies: + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" + is-promise "^2.1.0" + is-stream "^1.1.0" + listr-silent-renderer "^1.1.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" + +lodash.once@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + +lodash@^4.17.19: + version "4.17.20" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + +log-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg= + dependencies: + chalk "^1.0.0" + +log-symbols@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= + dependencies: + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +mime-db@1.45.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: + 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.45.0" + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +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== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +mkdirp@^0.5.4: + version "0.5.5" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +moment@^2.29.1: + version "2.29.1" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +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== + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" + integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +ospath@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" + integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= + +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^2.2.0: + version "2.3.0" + resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pretty-bytes@^5.4.1: + version "5.5.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" + integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +psl@^1.1.28: + version "1.8.0" + resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +ramda@~0.26.1: + version "0.26.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + +readable-stream@^2.2.2: + version "2.3.7" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +request-progress@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" + integrity sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4= + dependencies: + throttleit "^1.0.0" + +restore-cursor@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" + integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE= + dependencies: + exit-hook "^1.0.0" + onetime "^1.0.0" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rxjs@^6.3.3: + 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" + +safe-buffer@^5.0.1, safe-buffer@^5.1.2: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +slice-ansi@0.0.4: + version "0.0.4" + resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" + integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0, supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +throttleit@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" + integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= + +tmp@~0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tslib@^1.9.0: + 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" + resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typescript@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +untildify@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" + integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +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= + +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" diff --git a/docs/FAQ.md b/docs/FAQ.md index 9c11b015ea..da10f9724c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,6 +117,62 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. +### How do I keep my Backstage app up to date? + +In many ways one can view Backstage as a library rather than an application or +service. The `@backstage/create-app` tool that is used to create your own +Backstage app is similar to +[`create-react-app`](https://github.com/facebook/create-react-app) in that it +gives you a starting point. The code you get is meant to be evolved, and most of +the functionality you get out of the box is brought in via npm dependencies. +Keeping your app up to date generally means keeping your dependencies up to +date. The Backstage CLI provides a command to help you with that. Simply run +`yarn backstage-cli versions:bump` at the root of your repo, and the latest +versions of all Backstage packages will be installed. + +While staying up to date with new releases and changes will keep your app up to +date, it can often be convenient to use the changes done to the +`@backstage/create-app` template as another method to stay up to date. For that +purpose, any changes done to the template are documented along with upgrade +instructions in the +[changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md) +of the `@backstage/create-app` package. + +### Why can't I dynamically install plugins without modifications the app? + +This decision is part of the core architecture and development flow of +Backstage. Plugins have a lot of freedom in what they provide and how they are +integrated into the app, and it would therefore add a lot of complexity to allow +plugins to be integrated via configuration the same way as they can be +integrated with code. + +By bundling all plugins and their dependencies into one app bundle it is also +possible to do significant optimizations to the app load time by allowing +plugins to share dependencies between each other when possible. This contributes +to Backstage being fast, which is an important part of the user and developer +experience. + +### Why are there no published Docker images or helm charts for Backstage? + +As mentioned above, Backstage is not a packaged service that you can use out of +the box. In order to get started with Backstage you need to use the +`@backstage/create-app` package to create and customize your own Backstage app. + +In order to build a Docker image from your own app, you can use the +`yarn build-image` command which is included out of the box in the app template. +By default this image will bundle up both the frontend and the backend into a +single image that you can deploy using your favorite tooling. + +There are also some examples that can help you deploy Backstage to kubernetes in +the +[contrib](https://github.com/backstage/backstage/tree/master/contrib/kubernetes) +folder. + +It is possible that example images will be provided in the future, which can be +used to quickly try out a small subset of the functionality of Backstage, but +these would not be able to provide much more functionality on top of what you +can see on a demo site. + ### Do I have to write plugins in TypeScript? No, you can use JavaScript if you prefer. We want to keep the Backstage core diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 2547db5c5c..308544e0f1 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -33,10 +33,10 @@ hook exported by `@backstage/core`, or the `withApis` HOC if you prefer class components. For example, the `ErrorApi` can be accessed like this: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useApi, errorApiRef } from '@backstage/core'; -export const MyComponent: FC<{}> = () => { +export const MyComponent = () => { const errorApi = useApi(errorApiRef); // Signal to the app that something went wrong, and display the error to the user. @@ -119,7 +119,7 @@ Plugins supply their APIs through the `apis` option of `createPlugin`, for example: ```ts -export const plugin = createPlugin({ +export const techdocsPlugin = createPlugin({ id: 'techdocs', apis: [ createApiFactory({ @@ -181,7 +181,25 @@ The `IgnoringErrorApi` would then be imported in the app, and wired up like this: ```ts -builder.add(errorApiRef, new IgnoringErrorApi()); +const app = createApp({ + apis: [ + /* ApiFactories */ + createApiFactory(errorApiRef, new IgnoringErrorApi()), + + // OR + // If your API has dependencies, you use the object form + createApiFactory({ + api: errorApiRef, + deps: { configApi: configApiRef }, + factory({ configApi }) { + return new IgnoringErrorApi({ + reportingUrl: configApi.getString('error.reportingUrl'), + }); + }, + }), + ], + // ... other options +}); ``` Note that the above line will cause an error if `IgnoreErrorApi` does not fully 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 f91698c5ff..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 @@ -58,7 +54,7 @@ discover existing functionality in the ecosystem. APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. -framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs +framework APIs in Swift, Kotlin, Java, C++, TypeScript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 96f594daf7..eecb4ad87a 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -6,7 +6,7 @@ description: Architecture Decision Record (ADR) log on Avoid React.FC and React. ## Context -Facebook has removed `React.FC` from their base template for a Typescript +Facebook has removed `React.FC` from their base template for a TypeScript project. The reason for this was that it was found to be an unnecessary feature with next to no benefits in combination with a few downsides. @@ -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..a624a375e1 --- /dev/null +++ b/docs/assets/search/architecture.drawio.svg @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + + + + +
+
+
+ App Package: <Route path="/search" element={<... />} /> +
+
+
+
+ + App Package: <Route path="/search" element={<... />}... + +
+
+ + + + +
+
+
+ @backstage/ +
+ plugin-search-backend +
+
+
+
+ + @backstage/... + +
+
+ + + + +
+
+
+ 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 From Plugins + +
+
+
+
+ + Gather D... + +
+
+ + + + + + + +
+
+
+ + Register Document / Metadata Collation Handler(s) + +
+
+
+
+ + Register Docu... + +
+
+ + + + +
+
+
+ + 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... + +
+
+ + + + +
+
+
+ @backstage/ +
+ plugin-search-indexer +
+
+
+
+ + @backstage/... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index de71141ba0..c3460f98db 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/docs/assets/software-catalog/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/assets/techdocs/aws-s3.drawio.svg b/docs/assets/techdocs/aws-s3.drawio.svg new file mode 100644 index 0000000000..3bb730f91e --- /dev/null +++ b/docs/assets/techdocs/aws-s3.drawio.svg @@ -0,0 +1,144 @@ + + + + + + + + + + +
+
+
+ + + Admin + + User + +
+
+
+
+ + Admin User + +
+
+ + + + +
+
+
+ + + AWS Account + + +
+
+
+
+ + AWS Account + +
+
+ + + + +
+
+
+ + + TechDocs + + User + +
+
+
+
+ + TechDocs User + +
+
+ + + + +
+
+
+ + + S3 Bucket + + +
+
+
+
+ + S3 Bucket + +
+
+ + + + +
+
+
+ + User +
+ policy +
+
+
+
+
+ + User... + +
+
+ + + + +
+
+
+ + Bucket +
+ policy +
+
+
+
+
+ + Bucket... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 8dbae0daa7..a99ca119f0 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -61,16 +61,31 @@ If your authentication provider is any of the above mentioned providers, you can configure them by setting the right variables in `app-config.yaml` under the `auth` section. +### SAML + +The SAML Provider is currently under development. Additional validation and +profile handling is still required before use in production. + +To configure the SAML Auth provider, look at the configuration parameters +supported by +[Passport-SAML](https://github.com/node-saml/passport-saml#config-parameter-details) +under the `auth.providers.saml` key + +For security reasons, validate that the response from the IdP is indeed signed +by also providing the `cert` configuration. + ### Configuration -Each authentication provider (except SAML) needs five parameters: an OAuth -client ID, a client secret, an authorization endpoint, a token endpoint, and an -app origin. The app origin is the URL at which the frontend of the application -is hosted, and it is read from the `app.baseUrl` config. This is required -because the application opens a popup window to perform the authentication, and -once the flow is completed, the popup window sends a `postMessage` to the -frontend application to indicate the result of the operation. Also this URL is -used to verify that authentication requests are coming from only this endpoint. +Each authentication provider (except SAML) needs six parameters: an OAuth client +ID, a client secret, an authorization endpoint, a token endpoint, an optional +list of scopes (as a string separated by spaces) that may be required by the +OAuth2 Server to enable end-user sign-on, and an app origin. The app origin is +the URL at which the frontend of the application is hosted, and it is read from +the `app.baseUrl` config. This is required because the application opens a popup +window to perform the authentication, and once the flow is completed, the popup +window sends a `postMessage` to the frontend application to indicate the result +of the operation. Also this URL is used to verify that authentication requests +are coming from only this endpoint. These values are configured via the `app-config.yaml` present in the root of your app folder. @@ -96,6 +111,23 @@ auth: development: clientId: $env: + oauth2: + development: + clientId: + $env: AUTH_OAUTH2_CLIENT_ID + clientSecret: + $env: AUTH_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: AUTH_OAUTH2_AUTH_URL + tokenUrl: + $env: AUTH_OAUTH2_TOKEN_URL + scope: + $env: AUTH_OAUTH2_SCOPE + saml: + entryPoint: + $env: AUTH_SAML_ENTRY_POINT + issuer: + $env: AUTH_SAML_ISSUER ... ``` 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/cli/commands.md b/docs/cli/commands.md new file mode 100644 index 0000000000..46cca46f78 --- /dev/null +++ b/docs/cli/commands.md @@ -0,0 +1,608 @@ +--- +id: commands +title: Commands +description: Descriptions of all commands available in the CLI. +--- + +This page lists all commands provided by the Backstage CLI, what they're for, +and where to use them. + +The documentation for each command begins with specifying its scope, this +indicates where the command should be used by selecting from the following list: + +- `app` - A frontend app package, such as `packages/app`. +- `backend` - A backend package, such as `packages/backend`. +- `frontend-plugin` - A frontend plugin package. +- `backend-plugin` - A backend plugin package. +- `root` - The monorepo root. +- `any` - Any kind of package, but not the repo root. + +## help + +This command displays a help summary or detailed help screens for each command. +Below is a cleaned up output of `yarn backstage-cli --help`. + +```text +app:build Build an app for a production release +app:serve Serve an app for local development + +backend:build Build a backend plugin +backend:bundle Bundle the backend into a deployment archive +backend:build-image Bundles the package into a docker image +backend:dev Start local development server with HMR for the backend + +plugin:build Build a plugin +plugin:diff Diff an existing plugin with the creation template +plugin:serve Serves the dev/ folder of a plugin + +build Build a package for publishing +build-workspace Builds a temporary dist workspace from the provided packages +lint Lint a package +test Run tests, forwarding args to Jest, defaulting to watch mode +clean Delete cache directories + +create-plugin Creates a new plugin in the current repository +remove-plugin Removes plugin in the current repository + +config:print Print the app configuration for the current package +config:check Validate that the given configuration loads and matches schema +config:schema Dump the app configuration schema + +versions:bump Bump Backstage packages to the latest versions +versions:check Check Backstage package versioning + +prepack Prepares a package for packaging before publishing +postpack Restores the changes made by the prepack command + +help [command] display help for command +``` + +## app:build + +Scope: `app` + +Builds a bundle of static content from the app, which can then be served via any +static web server such as `nginx`, or via the +[`app-backend`](https://www.npmjs.com/package/@backstage/plugin-app-backend) +plugin directly from a Backstage backend instance. + +The command also reads and injects static configuration into the bundle. It is +important to note that when deploying using your own static content hosting +solution, this will be the final configuration used in the frontend unless you +for example hook in configuration loading from the backend. When using the +`nginx` based Dockerfile in this repo along with its included run script, +`APP_CONFIG_` environment variables will be injected into the frontend, and when +serving using the `app-backend` plugin, the configuration is completely injected +from the backend and the configuration at the time of calling this command will +not be used. + +Note that even when injecting configuration at runtime, it is not possible to +change the base path of the app. For example, if you at build time have +`app.baseUrl` set to `http://dev-app.com/my-app`, you can change that to +`https://prod-app.com/my-app`, but not to `https://prod-app.com`, as that would +change the path. + +During the build, the following variables are set: + +```java +process.env.NODE_ENV = 'production'; +process.env.BUILD_INFO = { + cliVersion: '0.4.0', // The version of the CLI package + gitVersion: 'v0.4.0-86-ge54815618', // output of `git describe --always` + packageVersion: '1.0.5', // The version of the app package itself + timestamp: 1678900000000, // Date.now() when the build started + commit: 'e548156182a973ed4b459e18533afc22c85ffff8', // output of `git rev-parse HEAD` +}; +``` + +Some CI environments do not properly report correct resource limits, potentially +leading to errors such as `ENOMEM` during compilation. If you run into this +issue you can limit the parallelization of the build process by setting the +environment variable `BACKSTAGE_CLI_BUILD_PARALLEL`, which is forwarded to the +[`terser-webpack-plugin`](https://github.com/webpack-contrib/terser-webpack-plugin#parallel). +You can set it to `false` or `1` to completely disable parallelization, but +usually a low value such as `2` is enough. + +```text +Usage: backstage-cli app:build + +Options: + --stats Write bundle stats to output directory + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## app:serve + +Scope: `app` + +Serve an app for local development. This starts up a local development server, +using a bundling configuration that is quite similar to that of the `app:build` +command, but with development features such as React Hot Module Replacement, +faster sourcemaps, no minification, etc. + +The static configuration is injected into the frontend, but it does not support +watching, meaning that changes in for example `app-config.yaml` are not +reflected until the serve process is restarted. + +During the build, the following variables are set: + +```java +process.env.NODE_ENV = 'development'; +process.env.BUILD_INFO = { /* See app:build */ }; +``` + +The server listening configuration is controlled through the static +configuration. The `app.baseUrl` determines the listening host and port, as well +as whether HTTPS is used or not. It is also possible to override the listening +host and port if needed by setting `app.listen.host` and `app.listen.port`. + +```text +Usage: backstage-cli app:serve [options] + +Options: + --check Enable type checking and linting + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## backend:build + +Scope: `backend-plugin` + +This builds a backend package for publishing and use in production. The build +output is written to `dist/`. Be sure to list any additional file that the +package depends on at runtime in the `"files"` field inside `package.json`, a +common example being the `migrations` directory. + +```text +Usage: backstage-cli backend:build [options] + +Options: + -h, --help display help for command +``` + +## backend:bundle + +Scope: `backend` + +Bundle the backend and all of its local dependencies into a deployment archive. +The archive is written to `dist/bundle.tar.gz`, and contains the packaged +version of all dependencies of the target package, along with the target package +itself. The layout of the packages in the archive is the same as the directory +layout in the target monorepo, and the bundle also contains the root +`package.json` and `yarn.lock`. + +To use the bundle, extract it into a target directory, run +`yarn install --production`, and then start the target backend package using for +example `node package/backend`. + +The `dist/bundle.tar.gz` is accompanied by a `dist/skeleton.tar.gz`, which has +the same layout, but only contains `package.json` files and `yarn.lock`. This +can be used to run a `yarn install` in environments that will benefit from the +caching that this enables, such as Docker image builds. To use the skeleton +archive, simply extract it first, run install, and then extract the main bundle. + +The following is an example of a `Dockerfile` that can be used to package the +output of `backstage-cli backend:bundle` into an image: + +```Dockerfile +FROM node:14-buster-slim +WORKDIR /app + +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + +CMD ["node", "packages/backend"] +``` + +```text +Usage: backstage-cli backend:bundle [options] + +Bundle the backend into a deployment archive + +Options: + --build-dependencies Build all local package dependencies before bundling the backend + -h, --help display help for command +``` + +## backend:build-image + +Scope: `backend` + +Builds a Docker image of the backend package, forwarding all unknown options to +`docker image build`. For example: + +```bash +yarn backstage-cli backend:build-image --build --tag my-backend-image +``` + +The image is built using the backend package along with all of its local package +dependencies. It expects to find a `Dockerfile` at the root of the backend +package, which will be used during the build. + +The Dockerfile is **NOT** executed within the package or repo itself. Because +the packages in the repo itself are configured for development instead of +production use, the final Docker build happens in a separate temporary +directory, to which the backend package and dependencies have been copied. Only +files listed within the `"files"` field within each package's `package.json` are +copied over, along with the root `package.json`, `yarn.lock`, and any +`app-config.*.yaml` files. + +During the build a `skeleton.tar` file is created and put at the repo root. This +file contains the `package.json` of each included package, which together with +the root `package.json` and `yarn.lock` can be used to run a cached +`yarn install` before the full production builds of all the packages are copied +over, providing a significant speedup if Docker build layer caching available. + +This command is experimental and we hope to be able to replace it with one that +is less integrated directly with Docker, and also supports multi-stage Docker +builds. It is possible to replicate most of what this command does by manually +building each package, and then use the `build-workspace` to create the +temporary workspace, and finally copy over any additional files to the workspace +and execute the Docker build within it. + +```text +Usage: backstage-cli backend:build-image [options] + +Options: + --build Build packages before packing them into the image + --backstage-cli-help display help for command +``` + +## backend:dev + +Scope: `backend`, `backend-plugin` + +Starts a backend package in development mode, with watch mode enabled for all +local dependencies. + +```text +Usage: backstage-cli backend:dev [options] + +Options: + --check Enable type checking and linting + --inspect Enable debugger + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## create-plugin + +Scope: `root` + +Creates a new plugin within the repository. This command is typically wrapped up +in the root `package.json` to be executed with `yarn create-plugin`, using +options that are appropriate for the organization that owns the app repo. A +recommended scope for internal packages is `@internal`. + +```text +Usage: backstage-cli create-plugin [options] + +Options: + --backend Create plugin with the backend dependencies as default + --scope <scope> npm scope + --npm-registry <URL> npm registry URL + --no-private Public npm package + -h, --help display help for command +``` + +## remove-plugin + +Scope: `root` + +A utility to remove a plugin from a repo, essentially undoing everything that +was done by `create-plugin`. + +This is primarily intended as a utility for manual tests and end to end testing +scripts. + +```text +Usage: backstage-cli remove-plugin [options] + +Options: + -h, --help display help for command +``` + +## plugin:build + +Scope: `frontend-plugin` + +Build a frontend plugin for publishing to a package registry. There is no need +to run this command during development or even in CI unless the package is being +published. The `app:bundle` command does not use the output for this command +when bundling local package dependencies. + +The output is written to a `dist/` folder. It also outputs type declarations for +the plugin, and therefore requires `yarn tsc` to have been run first. The input +type declarations are expected to be found within `dist-types/` at the root of +the monorepo. + +```text +Usage: backstage-cli plugin:build [options] + +Options: + -h, --help display help for command +``` + +## plugin:serve + +Scope: `frontend-plugin` + +Serves a frontend plugin by itself for isolated development. The serve task +itself is essentially identical to `app:serve`, but the entrypoint is instead +set to the `dev/` folder within the plugin. + +The `dev/` folder typically contains a small wrapper script that hooks up any +necessary mock APIs or other things that are needed for the plugin to function. +The `@backstage/dev-utils` package provides utilities to that end. + +```text +Usage: backstage-cli plugin:serve [options] + +Options: + --check Enable type checking and linting + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## plugin:diff + +Scope: `frontend-plugin` + +Compares a frontend plugin to the `create-plugin` template, making sure that it +hasn't diverged from the template and recommending updates when it has. A good +practice is to run this command after updating the version of the CLI in a +project. + +```text +Usage: backstage-cli plugin:diff [options] + +Options: + --check Fail if changes are required + --yes Apply all changes + -h, --help display help for command +``` + +## build + +Scope: `any` + +Build a single package for publishing, just like the `plugin:build` and +`backend:build` commands. This command is intended for standalone packages that +aren't plugins, and for example support building of isomorphic packages for +usage in both the frontend and backend. + +For frontend packages you'll want to include `esm` output, and for backend +packages `cjs`. Whether to include `types` depends on if you need type +declarations for the package, and also requires `yarn tsc` to have been run +first. + +```text +Usage: backstage-cli build [options] + +Options: + --outputs <formats> List of formats to output [types,cjs,esm] + -h, --help display help for command +``` + +## lint + +Scope: `any` + +Lint a package. In addition to the default `eslint` behavior, this command will +include TypeScript files, treat warnings as errors, and default to linting the +entire directory if no specific files are listed. + +```text +Usage: backstage-cli lint [options] + +Options: + --format <format> Lint report output format (default: "eslint-formatter-friendly") + --fix Attempt to automatically fix violations + -h, --help display help for command +``` + +## test + +Scope: `any` + +Run tests, forwarding all unknown options to Jest, and defaulting to watch mode. +When executing the tests, `process.env.NODE_ENV` will be set to `"test"`. + +This command uses a default Jest configuration that is included in the CLI, +which is set up with similar goals for speed, scale, and working within a +monorepo. The configuration sets the `src` as the root directory, enforces the +`.test.` infix for tests, and uses `src/setupTests.ts` as the test setup +location. + +If needed, the configuration can be extended using a `"jest"` field in +`package.json`, both within the target package and the monorepo root, with +configuration in the target package taking precedence. Refer to the +[Jest configuration documentation](https://jestjs.io/docs/en/configuration) for +a full list of configuration options. + +In addition to the Jest configuration there's an optional `transformModules` +option, which is an array of module names to include in transformations. +Normally modules inside `node_modules` are not transformed, but there are cases +were published packages are not transpiled far enough to be usable by Jest, in +which case you need to enable transform of them. + +Another way to override the Jest configuration is to place a `jest.config.js` or +`jest.config.ts` file in the package root. As opposed to the `package.json` way +of overriding config, this completely removes the base config, and so you need +to set it up from scratch. + +```text +Usage: backstage-cli test [options] + +Options: + --backstage-cli-help display help for command +``` + +## config:print + +Scope: `root` + +Print the static configuration, defaulting to reading `app-config.yaml` in the +repo root, using schema collected from all local packages in the repo. + +For example, to validate that a given configuration value is visible in the +frontend when building the `my-app` package, you can use the following: + +```bash +yarn backstage-cli config:print --frontend --package my-app +``` + +```text +Usage: backstage-cli config:print [options] + +Options: + --package <name> Only load config schema that applies to the given package + --frontend Print only the frontend configuration + --with-secrets Include secrets in the printed configuration + --format <format> Format to print the configuration in, either json or yaml [yaml] + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## config:check + +Scope: `root` + +Validate that static configuration loads and matches schema, defaulting to +reading `app-config.yaml` in the repo root and using schema collected from all +local packages in the repo. + +```text +Usage: backstage-cli config:check [options] + +Options: + --package <name> Only load config schema that applies to the given package + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## config:schema + +Scope: `root` + +Dump the configuration schema that was collected from all local packages in the +repo. + +Note: when run by `yarn`, supply the yarn option `--silent` if you are using the +output in a command line pipe to avoid non schema output in the pipeline. + +```text +Usage: backstage-cli config:schema [options] + +Print configuration schema + +Options: + --package <name> Only output config schema that applies to the given package + -h, --help display help for command + +``` + +## versions:bump + +Scope: `root` + +Bump all `@backstage` packages to the latest versions. This checks for updates +in the package registry, and will update entries both in `yarn.lock` and +`package.json` files when necessary. + +```text +Usage: backstage-cli versions:bump [options] + +Options: + -h, --help display help for command +``` + +## versions:check + +Scope: `root` + +Validate `@backstage` dependencies within the repo, making sure that there are +no duplicates of packages that might lead to breakages. For example, +`@backstage/core` must not be loaded in twice, so having two different versions +of it installed will cause this command to exit with an error. + +By supplying the `--fix` flag the command will attempt to fix any conflict that +can be resolved by editing `yarn.lock`, but will not attempt to search for +remote updates or modify any `package.json` files. + +```text +Usage: backstage-cli versions:check [options] + +Options: + --fix Fix any auto-fixable versioning problems + -h, --help display help for command +``` + +## prepack + +Scope: `any` + +This command should be added as `scripts.prepack` in all packages. It enables +packaging- and publish-time overrides for fields inside `packages.json`. + +The checked in version of all packages in a Backstage monorepo are tailored for +local development, and as such `main` and similar fields inside `package.json` +point to development source, i.e. `src/index.ts`. Using this when publishing +would lead to a broken package, since `src/` is not included in the published +package and we instead need to point to files in the `dist/` directory. This +command allows for those fields to be rewritten when needed, and does so by +copying all fields within `publishConfig` to the top-level of each +`package.json`, skipping `access`, `registry`, and `tag`. + +The need for this command may be removed in the future, as this exact method of +overriding fields for publishing is already supported by some package managers. + +```text +Usage: backstage-cli prepack [options] + +Options: + -h, --help display help for command +``` + +## postpack + +Scope: `any` + +This should be added as `scripts.postpack` in all packages. It restores +`package.json` to what it looked like before calling the `prepack` command. + +```text +Usage: backstage-cli postpack [options] + +Options: + -h, --help display help for command +``` + +## clean + +Scope: `any` + +Remove cache and output directories. + +```text +Usage: backstage-cli clean [options] + +Options: + -h, --help display help for command +``` + +## build-workspace + +Scope: `any`, `root` + +Builds a mirror of the workspace using the packaged production version of each +package. This essentially calls `yarn pack` in each included package and unpacks +the resulting archive in the target `workspace-dir`. + +```text +Usage: backstage-cli build-workspace [options] <workspace-dir> +``` diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000000..1b2e01be62 --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,108 @@ +--- +id: index +title: Overview +description: Overview of the Backstage CLI +--- + +## Summary + +Backstage provides an opinionated set of tooling for both frontend and backend +development. It is delivered through the +[`@backstage/cli`](https://www.npmjs.com/package/@backstage/cli) package and +executed either directly through `yarn backstage-cli ` or within +`package.json` scripts. When creating an app using +[`@backstage/create-app`](https://www.npmjs.com/package/@backstage/create-app) +it contains package scripts for executing the most common commands. + +Under the hood the CLI uses [Webpack](https://webpack.js.org/) for bundling, +[Rollup](https://rollupjs.org/) for building packages, +[Jest](https://jestjs.io/) for testing, and [eslint](https://eslint.org/) for +linting. It also includes custom tooling for working within Backstage apps, for +example for keeping the app up to date and verifying static configuration. + +For a full list of CLI commands, see the [commands](./commands.md) page. + +## Introduction + +A goal of Backstage is to provide a delightful developer experience in and +around the project. Creating new apps and plugins should be simple, iteration +speed should be fast, and the overhead of maintaining custom tooling should be +minimal. As a part of accomplishing this goal, Backstage provides its own set of +opinionated tooling, delivered primarily through the +[`@backstage/cli`](https://www.npmjs.com/package/@backstage/cli) package. + +The `@backstage/cli` package provides a single executable script, +`backstage-cli`, which you can run directly with `yarn` or within a script in +`package.json`. If you have a Backstage app set up, you can try out the +following command to print the top-level help page of the CLI: + +```text +yarn backstage-cli --help +``` + +If you are familiar with [`create-react-app`](https://create-react-app.dev/) you +may recognize the pattern of bundling tooling up as a CLI, as it uses a package +called [`react-scripts`](https://www.npmjs.com/package/react-scripts) to bring +most of the functionality into the created project. The Backstage equivalent of +`create-react-app` is +[`@backstage/create-app`](https://www.npmjs.com/package/@backstage/create-app), +and the equivalent of `react-scripts` is `@backstage/cli`. There are however a +couple of key differences between the two. Most notably, Backstage apps are +monorepos and the CLI is tailored for that environment. It provides tooling both +for bundling and developing full end-user apps, but also for developing, +building and publishing individual packages within the monorepo, as well as +tooling that is more unique to Backstage, such as commands for working with +static configuration. + +## Opinionated Tooling + +The Backstage CLI is highly opinionated in what tools are used and how they are +configured. It is tailored for development in large TypeScript monorepos with +hundreds of separate packages, but with the ability to have edits anywhere in +the codebase reflected within a few seconds. The build output is also optimized +for this setup, and aims to provide an excellent user experience with fast page +load times in modern browsers, rather than a wide range of support. + +While the Backstage tooling is opinionated in how to develop and build packages, +it is also possible to use your own tooling either partially or fully. For +example, the CLI provides a command for building a plugin package for +publishing, but the output is a quite standard combination of transpiled +JavaScript and TypeScript type declarations. The usage of the command from the +CLI can therefore easily be replaced with other tools if necessary. + +Just like `react-scripts`, the Backstage CLI does not provide many hooks for +overriding or customizing the build process. This is to allow for evolution of +the CLI without having to take a wide API surface into account. This allows us +to quickly iterate and improve the tooling, as well as to more easily keep +dependencies up to date. + +## Opinions & Goals + +In no particular order, this is a list of opinions and goals that guide the +design and development of the Backstage CLI: + +- All you need for development is `yarn start`, there should be no need to + manually build packages or run other separate tasks. +- Development experience comes first. The toolchain is optimized for keeping + development smooth, rather than making it easy to for example build and + publish packages. +- Type checking and linting is left for text editors and Continuous Integration. + Most text editors provide tooling for these checks, and running them a second + time during compilation slows down iteration speed and consumes more system + resources. +- Backstage is run in modern browsers. We keep transpilation lightweight and + rely on modern technologies such as HTTP/2 to optimize frontend speed. + +## Glossary + +- **Package** - A package in the Node.js ecosystem, often published to a package + registry such as [NPM](https://www.npmjs.com/). +- **Monorepo** - A project layout that consists of multiple packages within a + single project, where packages are able to have local dependencies on each + other. Often enabled through tooling such as [lerna](https://lerna.js.org/) + and [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) +- **Local Package** - One of the packages within a monorepo. These package may + or may not also be published to a package registry. +- **Bundle** - A collection of the deployment artifacts. The output of the + bundling process, which brings a collection of packages into a single + collection of deployment artifacts. diff --git a/docs/conf/defining.md b/docs/conf/defining.md index ead5c4ff67..34b9b11977 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -36,6 +36,10 @@ export interface Config { * @visibility frontend */ baseUrl: string; + + // Use @items. to assign annotations to primitive array items + /** @items.visibility frontend */ + myItems: string[]; }; } ``` @@ -83,6 +87,12 @@ object will be available in the frontend. The full ancestry does not need to have correctly defined visibilities however, so it is enough to only for example declare the visibility of a leaf node of `type: "string"`. +| `visibility` | | +| ------------ | ------------------------------------------------------------------ | +| `frontend` | Visible in frontend and backend | +| `backend` | (Default) Only in backend | +| `secret` | Only in backend and may be excluded from logs for security reasons | + ## Validation Schemas can be validated using the `backstage-cli config:check` command. If you 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 f28dd5b2d3..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,43 +114,42 @@ 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 ``` -### Data File Secrets +### Including Files -This reads secrets from a path within a JSON-like data file. The file path -behaves similar to file secrets, but with the addition of a url fragment that is -used to point to a specific value inside the file. Supported file extensions are -`.json`, `.yaml`, and `.yml`. For example, the following would read out -`my-secret-key` from `my-secrets.json`: +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`: ```yaml -$data: ./my-secrets.json#deployment.key +$include: ./my-secrets.json#deployment.key ``` Example `my-secrets.json` file: @@ -162,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/design.md b/docs/dls/design.md index 3bf283b77f..042312d0a3 100644 --- a/docs/dls/design.md +++ b/docs/dls/design.md @@ -33,15 +33,18 @@ our users. ### Transparent There are a lot of exciting things coming up and we want to keep you in the -loop! Keep an eye on our Milestones in GitHub to see where we’re headed. We’ll -also be posting updates in the _#design_ channel on -[Discord](https://discord.gg/EBHEGzX). Not only that, we want to keep you -informed on the decisions we’ve made and why we’ve made them. +loop! Keep an eye on our +[Milestones in GitHub](https://github.com/backstage/backstage/milestones) to see +where we're headed and review the +[open design issues](https://github.com/backstage/backstage/issues?q=is%3Aopen+is%3Aissue+label%3Adesign), +to see if you can help. We'll also be posting updates in the _#design_ channel +on [Discord](https://discord.gg/EBHEGzX). Not only that, we want to keep you +informed on the decisions we've made and why we've made them. ## 🛠 Our Practice -The chart below details how we work. **_Stay tuned_**: We are currently in the -process of securing a Figma workspace for Backstage Open Source, and we plan on +The chart below details how we work. We have a +[Figma workspace for Backstage Open Source](figma.md), and we plan on referencing Figma documents to share specs and prototypes with the community. ### Creating a New Design Component 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..118afa6710 --- /dev/null +++ b/docs/features/search/architecture.md @@ -0,0 +1,48 @@ +--- +id: architecture +title: Search Architecture +description: Documentation on Search Architecture +--- + +# Search Architecture + +> _This is a proposed architecture which has not been implemented yet. Find our +> milestones to follow our progress on the +> [Search Roadmap](./README.md#project-roadmap)._ + +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 including, but not limited to, entities in the software catalog. + Searchable content won't be required to relate directly to the software + catalog, but by convention, we may encourage loose relationships using + well-known field names or attributes. +- 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 possible for any plugin to expose new content to search. (e.g. + entity metadata, documentation from TechDocs) +- It should be possible for any plugin to append relevant metadata to existing + content in search. (e.g. location (path) for TechDocs page) +- It should be possible to refine search queries (e.g. ranking, scoring, etc.) +- It should be possible to customize the search UI +- It should be possible to add search functionality to any Backstage plugin or + deployment + +Architecture non-goals: + +- At this time, we do not intend to directly support event-driven or incremental + index management. Instead, we'll be focused on scheduled, bulk index + management. 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 19c5c74f68..6658dfc055 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. @@ -183,7 +205,8 @@ described below. In addition to these, you may add any number of other fields directly under `metadata`, but be aware that general plugins and tools may not be able to -understand their semantics. +understand their semantics. See [Extending the model](extending-the-model.md) +for more information. ### `name` [required] @@ -192,8 +215,8 @@ entity, and for machines and other components to reference the entity (e.g. in URLs or from other entity specification files). Names must be unique per kind, within a given namespace (if specified), at any -point in time. Names may be reused at a later time, after an entity is deleted -from the registry. +point in time. This uniqueness constraint is case insensitive. Names may be +reused at a later time, after an entity is deleted from the registry. Names are required to follow a certain format. Entities that do not follow those rules will not be accepted for registration in the catalog. The ruleset is @@ -204,19 +227,7 @@ follows. - Must consist of sequences of `[a-z0-9A-Z]` possibly separated by one of `[-_.]` -Example: `visits-tracking-service`, `CircleciBuildsDump_avro_gcs` - -In addition to this, names are passed through a normalization function and then -compared to the same normalized form of other entity names and made sure to not -collide. This rule of uniqueness exists to avoid situations where e.g. both -`my-component` and `MyComponent` are registered side by side, which leads to -confusion and risk. The normalization function is also configurable, but the -default behavior is as follows. - -- Strip out all characters outside of the set `[a-zA-Z0-9]` -- Convert to lowercase - -Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs` +Example: `visits-tracking-service`, `CircleciBuildsDumpV2_avro_gcs` ### `namespace` [optional] @@ -226,7 +237,8 @@ the same format restrictions as `name` above. This field is optional, and currently has no special semantics apart from bounding the name uniqueness constraint if specified. It is reserved for future use and may get broader semantic implication later. For now, it is recommended -to not specify a namespace unless you have specific need to do so. +to not specify a namespace unless you have specific need to do so. This means +the entity belongs to the `"default"` namespace. Namespaces may also be part of the catalog, and are `v1` / `Namespace` entities, i.e. not Backstage specific but the same as in Kubernetes. @@ -256,7 +268,6 @@ most 253 characters in total. The name part must be sequences of `[a-zA-Z0-9]` separated by any of `[-_.]`, at most 63 characters in total. The `backstage.io/` prefix is reserved for use by Backstage core components. -Some keys such as `system` also have predefined semantics. Values are strings that follow the same restrictions as `name` above. @@ -268,7 +279,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 +312,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 +420,8 @@ metadata: spec: type: website lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team + system: artist-engagement-portal providesApis: - artist-api ``` @@ -407,7 +447,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.lifecycle` [required] @@ -427,8 +467,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,40 +480,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.implementsApis` [optional] +### `spec.system` [optional] -**NOTE**: This field was marked for deprecation on Nov 25nd, 2020. It will be -removed entirely from the model on Dec 14th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. +An [entity reference](#string-references) to the system that the component +belongs to, e.g. `artist-engagement-portal`. This field is optional. -Links APIs that are implemented by the component, e.g. `artist-api`. 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) | -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +### `spec.subcomponentOf` [optional] -This field has the same behavior as `spec.providesApis`. +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 @@ -558,7 +603,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.templater` [required] @@ -612,7 +657,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: @@ -678,8 +724,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 @@ -691,9 +737,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] @@ -723,10 +778,12 @@ metadata: 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 - ancestors: [ops, global-synergies, acme-corp] children: [backstage, other] - descendants: [backstage, other, team-a, team-b, team-c, team-d] ``` In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) @@ -749,6 +806,14 @@ Some common values for this field could be: - `product-area` - `root` - as a common virtual root of the hierarchy, if desired +### `spec.profile` [optional] + +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. + ### `spec.parent` [optional] The immediate parent group in the hierarchy, if any. Not all groups must have a @@ -756,30 +821,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). -### `spec.ancestors` [required] - -**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be -removed entirely from the model on Dec 6th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. - -The recursive list of parents up the hierarchy, by stepping through parents one -by one. The list must be present, but may be empty if `parent` is not present. -The first entry in the list is equal to `parent`, and then the following ones -are progressively farther up the hierarchy. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +| [`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] @@ -789,30 +835,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). -### `spec.descendants` [required] - -**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be -removed entirely from the model on Dec 6th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. - -The immediate and recursive child groups of this group in the hierarchy -(children, and children's children, etc.). The list must be present, but may be -empty if there are no child groups. The items are not guaranteed to be ordered -in any particular way. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +| [`kind`](#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 @@ -868,23 +895,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/installation.md b/docs/features/software-catalog/installation.md index b2afd62878..ae8c671ed1 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -30,33 +30,59 @@ it doesn't. Add the following entry to the head of your `packages/app/src/plugins.ts`: ```ts -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; +export { catalogPlugin } from '@backstage/plugin-catalog'; ``` -Add the following to your `packages/app/src/apis.ts`: +Next we need to install the two pages that the catalog plugin provides. You can +choose any name for these routes, but we recommend the following: + +```tsx +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, +} from '@backstage/plugin-catalog'; + +// Add to the top-level routes, directly within +} /> +}> + {/* + This is the root of the custom entity pages for your app, refer to the example app + in the main repo or the output of @backstage/create-app for an example + */} + + +``` + +The catalog plugin also has one external route that needs to be bound for it to +function: the `createComponent` route which should link to the page where the +user can create components. In a typical setup the create component route will +be linked to the Scaffolder plugin's template index page: ```ts -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { catalogPlugin } from '@backstage/plugin-catalog'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -// Inside the ApiRegistry builder function ... - -builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: backendUrl, - basePath: '/catalog', - }), -); +const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, +}); ``` -Where `backendUrl` is the `backend.baseUrl` from config, i.e. -`const backendUrl = config.getString('backend.baseUrl')`. +You may also want to add a link to the catalog index page to your sidebar: -The catalog components depend on a number of other -[Utility APIs](../../api/utility-apis.md) to function, including at least the -`ErrorApi` and `StorageApi`. You can find an example of how to install these in -your app -[here](https://github.com/backstage/backstage/blob/61c3a7e5b750dc7c059ef16b188594d31b2c04c2/packages/app/src/apis.ts#L80). +```tsx +import HomeIcon from '@material-ui/icons/Home'; + +// Somewhere within the +; +``` + +This is all that is needed for the frontend part of the Catalog plugin to work! ## Gotchas that we will fix diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index 53f49d5df9..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 @@ -44,8 +44,8 @@ Backstage model and the primary way to discover existing functionality in the ecosystem. APIs are implemented by components and form boundaries between components. They -might be defined using an RPC IDL (eg Protobuf, GraphQL, ...), a data schema (eg -Avro, TFRecord, ...), or as code interfaces. In any case, APIs exposed by +might be defined using an RPC IDL (e.g., Protobuf, GraphQL, ...), a data schema +(e.g., Avro, TFRecord, ...), or as code interfaces. In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. @@ -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..8ec4813799 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,19 +40,65 @@ 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 annotation is specified, it is expected to point to a repository that the TechDocs system can read and generate docs from. +### backstage.io/view-url, backstage.io/edit-url + +```yaml +# Example: +metadata: + annotations: + backstage.io/view-url: https://some.website/catalog-info.yaml + backstage.io/edit-url: https://github.com/my-org/catalog/edit/master/my-service.jsonnet +``` + +These annotations allow customising links from the catalog pages. The view URL +should point to the canonical metadata YAML that governs this entity. The edit +URL should point to the source file for the metadata. In the example above, +`my-org` generates its catalog data from Jsonnet files in a monorepo, so the +view and edit links need changing. + +### backstage.io/source-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/source-location: github:https://github.com/my-org/my-service +``` + +A `Location` reference that points to the source code of the entity (typically a +`Component`). Useful when catalog files do not get ingested from the source code +repository itself. + ### jenkins.io/github-folder ```yaml 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..9aa1d3bc8f 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -33,27 +33,27 @@ it doesn't. Add the following entry to the head of your `packages/app/src/plugins.ts`: ```ts -export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; ``` -Add the following to your `packages/app/src/apis.ts`: +Next we need to install the root page that the Scaffolder plugin provides. You +can choose any path for the route, but we recommend the following: -```ts -import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder'; +```tsx +import { ScaffolderPage } from '@backstage/plugin-scaffolder'; -// Inside the ApiRegistry builder function ... - -builder.add( - scaffolderApiRef, - new ScaffolderApi({ - apiOrigin: backendUrl, - basePath: '/scaffolder/v1', - }), -); +// Add to the top-level routes, directly within +} />; ``` -Where `backendUrl` is the `backend.baseUrl` from config, i.e. -`const backendUrl = config.getString('backend.baseUrl')`. +You may also want to add a link to the template index page to your sidebar: + +```tsx +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; + +// Somewhere within the +; +``` This is all that is needed for the frontend part of the Scaffolder plugin to work! @@ -85,29 +85,25 @@ following contents to get you up and running quickly. import { CookieCutter, createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisibilityOptions, } from '@backstage/plugin-scaffolder-backend'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { CatalogClient } from '@backstage/catalog-client'; 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); @@ -115,12 +111,19 @@ export default async function createPlugin({ const publishers = await Publishers.fromConfig(config, { logger }); const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + return await createRouter({ preparers, templaters, publishers, logger, + config, dockerClient, + database, + catalogClient, }); } ``` @@ -173,7 +176,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 +195,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 +213,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 +229,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 16131a1ded..2fa48f57b0 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? @@ -22,100 +22,90 @@ about TechDocs and the philosophy in its ## Features -- A centralized place to discover and read documentation. +- Deploy TechDocs no matter how your software environment is set up. +- Discover your Service's technical documentation from the Service's page in + Backstage Catalog. +- Create documentation-only sites for any purpose by just writing Markdown. +- Explore and take advantage of the large ecosystem of + [MkDocs plugins](https://www.mkdocs.org/user-guide/plugins/) to create a rich + reading experience. +- Search for and find docs (coming soon). +- Highlight text and raise an Issue to create feedback loop to drive quality + documentation (future). +- Contribute to and deploy from a marketplace of TechDocs widgets (future). -- A clear end-to-end docs-like-code solution. +## Platforms supported -- A tightly coupled feedback loop with the developer workflow. (_Coming soon in - V.3_) +See [TechDocs Architecture](architecture.md) to get an overview of where these +providers are used. -- A developer ecosystem for creating extensions. (_Coming soon in V.3_) +| Source Code Hosting Provider | Support Status | +| ---------------------------- | -------------- | +| GitHub | Yes ✅ | +| GitHub Enterprise | Yes ✅ | +| BitBucket | Yes ✅ | +| Azure DevOps | Yes ✅ | +| GitLab | Yes ✅ | +| GitLab Enterprise | Yes ✅ | + +| File Storage Provider | Support Status | +| --------------------------------- | -------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Blob Storage | Yes ✅ | + +[Reach out to us](#feedback) if you want to request more platforms. ## Project roadmap -| Version | Description | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [TechDocs V.0 ✅][v0] | Read docs in Backstage - Enable anyone to get a reader experience working in Backstage. [See V.0 Use Cases.](#techdocs-v0) | -| [TechDocs V.1 ✅][v1] | TechDocs end to end (alpha) - Alpha of TechDocs that you can use end to end - and contribute to. [See V.1 Use Cases.](#techdocs-v1) | -| [TechDocs V.2 🔮⌛][v2] | Easy adoption of TechDocs (whatever environment you have) [See V.2 Use Cases.](#techdocs-v2) | -| [TechDocs V.3 🔮⌛][v3] | Build a widget (plugin) framework so that contributors can easily contribute features to TechDocs - that others can use. [See V.3 Use Cases.](#techdocs-v3) | +### **Ongoing work 🚧** -[v0]: https://github.com/backstage/backstage/milestone/15 -[v1]: https://github.com/backstage/backstage/milestone/16 -[v2]: https://github.com/backstage/backstage/milestone/22 -[v3]: https://github.com/backstage/backstage/milestone/17 +**Beta release** - +[Milestone](https://github.com/backstage/backstage/milestone/29) - +- It should be possible and easy to use TechDocs in most environments across + organizations. +- Minimal bugs, better error handling and scalable backend and frontend. +- Documentation Search +- TechDocs Homepage with basic features -## Use Cases +### **Future work 🔮** -#### TechDocs V.0 +**General Availability (GA) release** - +[Milestone](https://github.com/backstage/backstage/milestone/30) -- As a user I can navigate to a manually curated docs explore page. -- As a user I can navigate to and read mock documentation that is manually - uploaded by the TechDocs core team. +- Bugs are rare, TechDocs APIs are stable and scales easily in large + organizations. +- Better integration with + [Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy + to choose and plug documentation template with Software Templates). +- Possible to configure several aspects about TechDocs (e.g. URL, homepage, + theme). -#### TechDocs V.1 +**Implement Feedback loop** - +[Milestone](https://github.com/backstage/backstage/milestone/31) -- As a user I can run TechDocs locally and read documentation. -- As a user I can create a docs folder in my entity project and add a reference - in the entity configuration file (of the owning entity) to my documentation. -- Backstage will automatically build my documentation and serve it in TechDocs. -- Documentation will be displayed under the docs tab in the service catalog. -- As a user I can create a docs only repository that will be standalone from any - other service. -- As a user I can choose my own storage solution for the documentation (as - example GCS/AWS/Azure etc) -- As a user I can define my own API to interface my own documentation solution. +- A feedback loop between documentation reader and writer using TechDocs +- The `+` in `docs-like-code+` experience -Extra platform stability and compatibility improvements: +**TechDocs widget framework** -- As a user I can define the metadata generated for my documentation. -- As a user I will be able to browse metadata from within my documentation in - Backstage. +Platformize TechDocs with a widget framework so that it is easy for TechDocs +contributors to add pieces of functionality and for users to choose which +functionalities they want to adopt. As a pre-requisite, the re-architecture of +TechDocs frontend [RFC](https://github.com/backstage/backstage/issues/3998) +needs to be addressed. -#### TechDocs V.2 +## Tech stack -We have a TechDocs that works end-to-end. The next step is to make it super easy -for companies to adopt. This involves (something like) the following work items. +| Stack | Location | +| ----------------------------------------------- | -------------------------------------------------------- | +| Frontend Plugin | [`@backstage/plugin-techdocs`][techdocs/frontend] | +| Backend Plugin | [`@backstage/plugin-techdocs-backend`][techdocs/backend] | +| CLI (for local development and generating docs) | [`@techdocs/cli`][techdocs/cli] | +| Docker Container (for generating docs) | [`techdocs-container`][techdocs/container] | -- “Solidify” work and “Mkdocs stabilization” work that has come out of our Q3 - end-to-end work. -- Improve/simplify the get up and running process. -- Introduce new documentation templates. -- Extend the already existing docs-template to have options of different - documentation types. -- Enable companies to choose their own storage (S3 for example). -- Enable companies to choose their own source code hosting provider (GitHub, - GitLab, and so). -- Share how to plug in TechDocs to your own CI. - -#### TechDocs V.3 - -Build a widget (plugin) framework so that contributors can easily contribute -features to TechDocs - that others can use. And, also, so that we can easily -migrate Spotify's existing TechDocs features to open source. - -## Structure - -- [Getting Started] -- [Concepts] -- [Creating and Publishing Documentation] -- [FAQ] - -## Tech Stack - -| Stack | Location | -| ------------------------------------------- | -------------------------------------------------------- | -| Frontend | [`@backstage/plugin-techdocs`][techdocs/frontend] | -| Backend | [`@backstage/plugin-techdocs-backend`][techdocs/backend] | -| Docker Container (for generating doc sites) | [`techdocs-container`][techdocs/container] | -| CLI (for local development) | [`@techdocs/cli`][techdocs/cli] | - -[getting started]: getting-started.md -[concepts]: concepts.md -[creating and publishing documentation]: creating-and-publishing.md -[faq]: FAQ.md 'Frequently asked questions' [techdocs/frontend]: https://github.com/backstage/backstage/blob/master/plugins/techdocs [techdocs/backend]: @@ -123,11 +113,7 @@ migrate Spotify's existing TechDocs features to open source. [techdocs/container]: https://github.com/backstage/techdocs-container [techdocs/cli]: https://github.com/backstage/techdocs-cli -## Feedback +## Contact us -We have created a sweet and short TechDocs user survey - -https://docs.google.com/forms/d/e/1FAIpQLSdn5Vn3MQhCdyYRuW8cMzZkMQF0bFxXYN168gZRvESLfJWVVg/viewform - -This is to gather inputs from you (the Backstage community) which will help us -best serve TechDocs adopters and existing users. Your inputs will shape our -roadmap and we will share it in the open. +Reach out to us in the `#docs-like-code` channel of our +[Discord chatroom](https://github.com/backstage/backstage#community). diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index f502b3836e..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. +all the JavaScript before adding them to Backstage for security reasons. And +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 build 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 -build 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 build 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 build 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. - -**Work in progress 🚧** - -- Basic setup with cloud storage solution. - -**Not implemented yet ❌** - -- `techdocs-cli` is able to generate docs in CI/CD environment. -- `techdocs-cli` is able to publish docs site to any storage. -- `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 1675c64288..408f092ebd 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -1,22 +1,52 @@ --- 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 solution in Backstage. -### TechDocs Core Plugin +## Generating TechDocs Steps -The TechDocs Core Plugin is an [MkDocs](https://www.mkdocs.org/) plugin created -as a wrapper around multiple MkDocs plugins and Python Markdown extensions to -standardize the configuration of MkDocs used for TechDocs. +### TechDocs Preparer -[TechDocs Core](https://github.com/backstage/mkdocs-techdocs-core) +Preparing is the first step of generating documentation for an entity. It +fetches the source markdown files from the source code hosting provider (GitHub, +GitLab, etc.) and passes the files to the generator for next steps. -### TechDocs container +There are two kinds of preparers available - + +1. Common Git Preparer - Uses `git clone` on any repository url. +2. Url Reader - Uses source code hosting provider's API to download files. + (Faster and recommended) + +### TechDocs Generator + +Generating is the second step after preparing the markdown source files. This +step either runs the TechDocs container (defined below) or runs `mkdocs` CLI to +generate static HTML files and its assets. + +### TechDocs Publisher + +Publishing is the third and final step after preparing and generating docs. +TechDocs Publisher uploads the generated files to a storage. + +The `techdocs-backend` plugin currently comes with two publishers - Google Cloud +Storage and Local Filesystem. You can configure them in your Backstage app. +[See here](./configuration.md). + +A TechDocs publisher is responsible for two things (two-way communication +between `techdocs-backend` and the storage) + +1. Publish generated static files to a storage (Configured by + `techdocs.builder`) +2. Read files from the storage when users visit a TechDocs site + +[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) + +## TechDocs Container The TechDocs container is a Docker container available at [DockerHub](https://hub.docker.com/r/spotify/techdocs). It builds static HTML @@ -25,16 +55,15 @@ MkDocs. [TechDocs Container](https://github.com/backstage/techdocs-container) -### TechDocs publisher +## TechDocs Core Plugin -The `techdocs-backend` plugin currently comes with one publisher - -`LocalPublish`. +The TechDocs Core Plugin is an [MkDocs](https://www.mkdocs.org/) plugin created +as a wrapper around multiple MkDocs plugins and Python Markdown extensions to +standardize the configuration of MkDocs used for TechDocs. -[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) +[TechDocs Core](https://github.com/backstage/mkdocs-techdocs-core) -More standalone publishers will come in the near future... - -### TechDocs CLI +## TechDocs CLI The TechDocs CLI was created to make it easy to write, generate and preview documentation for publishing. Currently it mostly acts as a wrapper around the @@ -43,7 +72,7 @@ container. [TechDocs CLI](https://github.com/backstage/techdocs-cli) -### TechDocs Reader +## TechDocs Reader Documentation generated by TechDocs is generated as static HTML sites. The TechDocs Reader was therefore created to be able to integrate pre-generated HTML @@ -55,7 +84,7 @@ TechDocs widgets for a customized full-featured TechDocs experience. [TechDocs Reader](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/reader/README.md) -### Transformers +## Transformers Transformers are different pieces of functionality used inside the TechDocs Reader. The reason why transformers were introduced was to provide a way to diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md new file mode 100644 index 0000000000..20e436d8a4 --- /dev/null +++ b/docs/features/techdocs/configuration.md @@ -0,0 +1,106 @@ +--- +id: configuration +title: TechDocs Configuration Options +# 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 +using several options. This page serves as a reference to all the available +configuration options for TechDocs. + +```yaml +# File: app-config.yaml + +techdocs: + # 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 + # into Docker in Docker situation. Read more here + # https://backstage.io/docs/features/techdocs/getting-started#disable-docker-in-docker-situation-optional + + 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. + # If builder is set to 'external', techdocs-backend will only fetch the docs and will NOT try to generate and publish. In this case of 'external', + # we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository). This is the "Recommended" setup of + # the architecture. Read more here https://backstage.io/docs/features/techdocs/architecture + + 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' 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: + # (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' + + # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. + + awsS3: + # (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: AWS_REGION + + # 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..a9844ef528 --- /dev/null +++ b/docs/features/techdocs/configuring-ci-cd.md @@ -0,0 +1,169 @@ +--- +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 + +# Install @techdocs/cli, mkdocs and mkdocs plugins +npm install -g @techdocs/cli +pip install mkdocs-techdocs-core==0.* + +# Generate +techdocs-cli generate --no-docker + +# Publish +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. + +## Steps + +### 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. + +## Example: GitHub Actions CI and AWS S3 + +Here is an example workflow using GitHub Actions CI and AWS S3 storage. You can +use any CI and any other +[TechDocs supported cloud storage providers](README.md#platforms-supported). + +Add a `.github/workflows/techdocs.yml` file in your +[Software Template(s)](../software-templates/index.md) like this - + +```yaml +name: Publish TechDocs Site + +on: + push: + branches: [main] + # You can even set it to run only when TechDocs related files are updated. + # paths: + # - "docs/**" + # - "mkdocs.yml" + +jobs: + publish-techdocs-site: + runs-on: ubuntu-latest + + # The following secrets are required in your CI environment for publishing files to AWS S3. + # e.g. You can use GitHub Organization secrets to set them for all existing and new repositories. + env: + TECHDOCS_S3_BUCKET_NAME: ${{ secrets.TECHDOCS_S3_BUCKET_NAME }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} + ENTITY_NAMESPACE: 'default' + ENTITY_KIND: 'Component' + ENTITY_NAME: 'my-doc-entity' + # In a Software template, Scaffolder will replace {{cookiecutter.component_id | jsonify}} + # with the correct entity name. This is same as metadata.name in the entity's catalog-info.yaml + # ENTITY_NAME: '{{ cookiecutter.component_id | jsonify }}' + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + - uses: actions/setup-python@v2 + + - name: Install techdocs-cli + run: sudo npm install -g @techdocs/cli + + - name: Install mkdocs and mkdocs plugins + run: python -m pip install mkdocs-techdocs-core==0.* + + - name: Generate docs site + run: techdocs-cli generate --no-docker --verbose + + - name: Publish docs site + run: + techdocs-cli publish --publisher-type awsS3 --storage-name + $TECHDOCS_S3_BUCKET_NAME --entity + $ENTITY_NAMESPACE/$ENTITY_KIND/$ENTITY_NAME +``` + +When the new repository is scaffolded or new documentation updates are +committed, the GitHub Action workflow will publish the TechDocs site, which can +be viewed in your Backstage app. diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 38f452dcf3..b9340330a8 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -41,7 +41,7 @@ setup for free. ### Manually add documentation setup to already existing repository -Prerequisities: +Prerequisites: - An existing component [registered in backstage](../software-catalog/index.md#adding-components-to-the-catalog) @@ -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 e51c6a122e..efed896fd0 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -1,7 +1,7 @@ --- id: getting-started title: Getting Started -description: Getting Started Guidelines +description: Getting Started Documentation --- TechDocs functions as a plugin to Backstage, so you will need to use Backstage @@ -10,73 +10,191 @@ to use TechDocs. If you haven't setup Backstage already, start [here](../../getting-started/index.md). -## Installing TechDocs +> If you used `npx @backstage/create-app`, TechDocs may already be present. +> +> You should skip to [`Setting the Configuration`](#setting-the-configuration) +> below. -TechDocs is provided with the Backstage application by default. If you want to -set up TechDocs manually, keep following the instructions below. - -### Adding the package +## Adding TechDocs frontend plugin The first step is to add the TechDocs plugin to your Backstage application. -Navigate to your new Backstage application folder: - -```bash -cd hello-world/ -``` - -Then navigate to your `packages/app` folder to install TechDocs: +Navigate to your new Backstage application directory. And then to your +`packages/app` directory, and install the `@backstage/plugin-techdocs` package. ```bash +cd my-backstage-app/ cd packages/app yarn add @backstage/plugin-techdocs ``` -After a short while, the TechDocs plugin should be successfully installed. +Once the package has been installed, you need to import the plugin in your app. -Next, you need to set up some basic configuration. Enter the following command: - -```bash -yarn install -``` - -Add this to `packages/app/src/plugins.ts`: +Add the following to `packages/app/src/plugins.ts`: ```typescript export { plugin as TechDocs } from '@backstage/plugin-techdocs'; ``` -### Setting the configuration +Now let us embed the TechDocs router in our main Backstage frontend router. In +`packages/app/src/App.tsx`, import the TechDocs router and add the following to +`AppRoutes`: -TechDocs allows for configuration of the docs storage URL through your -`app-config.yaml` file. We provide two different values to be configured, -`requestUrl` and `storageUrl`. The `requestUrl` is what the reader will request -its data from, and `storageUrl` is where the backend can find the stored -documentation. +```tsx +import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -The default storage and request URLs: +// ... + +const AppRoutes = () => { + + // ... other plugin routes + } /> + ; +}; +``` + +That's it! But now, we need the TechDocs Backend plugin for the frontend to +work. + +## Adding TechDocs Backend plugin + +Navigate to `packages/backend` of your Backstage app, and install the +`@backstage/plugin-techdocs-backend` package. + +```bash +cd my-backstage-app/ +cd packages/backend +yarn add @backstage/plugin-techdocs-backend +``` + +Create a file called `techdocs.ts` inside `packages/backend/src/plugins/` and +add the following + +```typescript +import { + createRouter, + Preparers, + Generators, + Publisher, +} from '@backstage/plugin-techdocs-backend'; +import { PluginEnvironment } from '../types'; +import Docker from 'dockerode'; + +export default async function createPlugin({ + logger, + config, + discovery, + reader, +}: PluginEnvironment) { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); + + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + + return await createRouter({ + preparers, + generators, + publisher, + dockerClient, + logger, + config, + discovery, + }); +} +``` + +You may need to install the `dockerode` package. But you may already have it in +your backend since [Scaffolder plugin](../software-templates/index.md) also uses +it. + +See [Concepts](concepts.md) and [TechDocs Architecture](architecture.md) to +learn more about how preparers, generators and publishers work. + +Final step is to import the techdocs backend plugin in Backstage app backend. +Add the following to your `packages/backend/src/index.ts`: + +```typescript +import techdocs from './plugins/techdocs'; + +// .... main should already be present. +async function main() { + // ... other backend plugin envs + const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + + // ... other backend plugin routes + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); +} +``` + +That's it! TechDocs frontend and backend have now been added to your Backstage +app. Now let us tweak some configurations to suit your needs. + +## Setting the configuration + +**See [TechDocs Configuration Options](configuration.md) for complete +configuration reference.** + +### Should TechDocs Backend generate docs? ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ + builder: 'local' ``` -If you want `techdocs-backend` to manage building and publishing, you want -`requestUrl` to point to the default value (or wherever `techdocs-backend` is -hosted). `storageUrl` should be where your publisher publishes your docs. Using -the default `LocalPublish` that is the default value. +Note that we recommend generating docs on CI/CD instead. Read more in the +"Basic" and "Recommended" sections of the +[TechDocs Architecture](architecture.md). But if you want to get started quickly +set `techdocs.builder` to `'local'` so that TechDocs Backend is responsible for +generating documentation sites. If set to `'external'`, Backstage will assume +that the sites are being generated on each entity's CI/CD pipeline, and are +being stored in a storage somewhere. -If you have a setup where you are not using `techdocs-backend` for managing -building and publishing of your documentation, you want to change the -`requestUrl` to point to your storage. In this case `storageUrl` is not -required. +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. -### Disable Docker in Docker situation (Optional) +### Choosing storage (publisher) -The TechDocs backend plugin runs a docker container with mkdocs installed to +TechDocs needs to know where to store generated documentation sites and where to +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. At a later time, review +[Using Cloud Storage](./using-cloud-storage.md). + +```yaml +techdocs: + builder: 'local' + publisher: + type: 'local' +``` + +### Disabling Docker in Docker situation (Optional) + +You can skip this if your `techdocs.builder` is set to `'external'`. + +The TechDocs Backend plugin runs a docker container with mkdocs installed to generate the frontend of the docs from source files (Markdown). If you are deploying Backstage using Docker, this will mean that your Backstage Docker -container will try to run another Docker container for TechDocs backend. +container will try to run another Docker container for TechDocs Backend. To avoid this problem, we have a configuration available. You can set a value in your `app-config.yaml` that tells the techdocs generator if it should run the @@ -85,6 +203,9 @@ no config is provided. ```yaml techdocs: + builder: 'local' + publisher: + type: 'local' generators: techdocs: local ``` @@ -95,36 +216,26 @@ 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. -## Run Backstage locally +## Running Backstage locally -Change folder to `/packages/backend` and run the -following command: +Start the frontend and the backend app by +[running Backstage locally](../../getting-started/running-backstage-locally.md). -```bash -yarn start -``` - -Open a new command line window. Change directory to your Backstage application -root and run the following command: - -```bash -yarn start -``` - -Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/). +Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/) +to see all your documentation sites. ## Additional reading 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 new file mode 100644 index 0000000000..cd4b058644 --- /dev/null +++ b/docs/features/techdocs/using-cloud-storage.md @@ -0,0 +1,311 @@ +--- +id: using-cloud-storage +title: Using Cloud Storage for TechDocs generated files +description: Using Cloud Storage for TechDocs generated files +--- + +In the [TechDocs architecture](./architecture.md) you have the option to choose +where you want to store the Generated static files which TechDocs uses to render +documentation. In both the "Basic" and "Recommended" setup, you can add cloud +storage providers like Google GCS, Amazon AWS S3, etc. By default, TechDocs uses +the local filesystem of the `techdocs-backend` plugin in the "Basic" setup. And +in the recommended setup, having one of the cloud storage is a prerequisite. +Read more on the TechDocs Architecture documentation page. + +On this page you can read how to enable them. + +## Configuring Google GCS Bucket with TechDocs + +Follow the +[official Google Cloud documentation](https://googleapis.dev/nodejs/storage/latest/index.html#quickstart) +for the latest instructions on the following steps involving GCP. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'googleGcs'`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' +``` + +**2. Create a GCS Bucket** + +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: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**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". + +If you want to create a custom role, make sure to include both `get` and +`create` permissions for both "Objects" and "Buckets". See +https://cloud.google.com/storage/docs/access-control/iam-permissions + +A service account can have many keys. Open your newly created account's page (in +IAM & Admin console), and create a new key. Use JSON format for the key. + +A `.json` file will be downloaded. This is the secret +key TechDocs will use to make API calls. Make it available in your Backstage +server and/or your local development server and set it in the app config +`techdocs.publisher.googleGcs.credentials`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' + credentials: + $file: '/path/to/google_application_credentials.json' +``` + +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: + bucketName: 'name-of-techdocs-storage-bucket' + credentials: + $env: GOOGLE_APPLICATION_CREDENTIALS +``` + +**4. That's it!** + +Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to +store and read the static generated documentation files. + +## Configuring AWS S3 Bucket with TechDocs + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'awsS3'`. + +```yaml +techdocs: + publisher: + type: 'awsS3' +``` + +**2. Create an S3 Bucket** + +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). + +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. + +Set the config `techdocs.publisher.awsS3.bucketName` in your `app-config.yaml` +to the name of the bucket you just created. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**3a. (Recommended) Setup authentication the AWS way, using environment +variables** + +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). + +TechDocs needs access to read files and metadata of the S3 bucket. + +If the environment variables + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `AWS_REGION` + +are set and can be used to access the bucket you created in step 2, they will be +used by the AWS SDK V2 Node.js client for authentication. +[Refer to the official documentation for loading credentials in Node.js from environment variables](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html). + +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/v2/developer-guide/loading-node-credentials-shared.html) + +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 in +[official AWS documentation for using IAM roles.](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html#use-roles). + +The AWS Region of the bucket is optional since TechDocs uses AWS SDK V2 and not +V3. + +**3b. Authentication using app-config.yaml** + +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: AWS_ACCESS_KEY_ID + secretAccessKey: + $env: AWS_SECRET_ACCESS_KEY +``` + +Refer to the +[official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html). + +**3c. Authentication using an assumed role** Users with multiple AWS accounts +may want to use a role for S3 storage that is in a different AWS account. Using +the `roleArn` parameter as seen below, you can instruct the TechDocs publisher +to assume a role before accessing S3. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' + region: + $env: AWS_REGION + credentials: + roleArn: arn:aws:iam::123456789012:role/my-backstage-role +``` + +Note: Assuming a role requires that primary credentials are already configured +at `AWS.config.credentials`. Read more about +[assuming roles in AWS](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). + +**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/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index 042d8964ca..64b9e772d1 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -11,7 +11,7 @@ add an existing plugin to it. We are using the [CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md) plugin in this example. -1. Add the plugin's NPM package to the repo: +1. Add the plugin's npm package to the repo: ```bash yarn add @backstage/plugin-circleci diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/contributors.md similarity index 65% rename from docs/getting-started/development-environment.md rename to docs/getting-started/contributors.md index c5275114ce..0cd52a3f7b 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/contributors.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 +id: contributors +title: Contributors +# 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 @@ -10,6 +10,21 @@ repository. ## Cloning the Repository +Ok. So you're gonna want some code right? Go ahead and fork the repository into +your own GitHub account and clone that code to your local machine or you can +grab the one for the origin like so: + +```bash +git clone git@github.com/backstage/backstage --depth 1 +``` + +If you cloned a fork, you can add the upstream dependency like so: + +```bash +git remote add upstream git@github.com:backstage/backstage +git pull upstream master +``` + After you have cloned the Backstage repository, you should run the following commands once to set things up for development: @@ -25,25 +40,42 @@ Open a terminal window and start the web app by using the following command from the project root. Make sure you have run the above mentioned commands first. ```bash -$ yarn start +$ yarn dev ``` +This is going to start two things, the frontend (:3000) and the backend (:7000). + 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`. Once successfully started, you should see the following message in your terminal window: +```sh +$ concurrently "yarn start" "yarn start-backend" +$ yarn workspace example-app start +$ yarn workspace example-backend start +$ backstage-cli app:serve +$ backstage-cli backend:dev +[0] Loaded config from app-config.yaml +[1] Build succeeded +[0] ℹ 「wds」: Project is running at http://localhost:3000/ +[0] ℹ 「wds」: webpack output is served from / +[0] ℹ 「wds」: Content not from webpack is served from $BACKSTAGE_DIR/packages/app/public +[0] ℹ 「wds」: 404s will fallback to /index.html +[0] ℹ 「wdm」: wait until bundle finished: / +[1] 2021-02-12T20:58:17.614Z backstage info Loaded config from app-config.yaml ``` -You can now view example-app in the browser. - Local: http://localhost:8080 - On Your Network: http://192.168.1.224:8080 -``` +You'll see how you get both logs for the frontend `webpack-dev-server` which +serves the react app ([0]) and the backend ([1]); + +Visit http://localhost:3000 and you should see the bleeding edge of Backstage +ready for contributions! ## Editor @@ -100,5 +132,10 @@ copy everything from the default config to the local config. `app-config.local.yaml` will be merged with `app-config.yaml` and overwrite the default app configs. +> NOTE: If you want to add your own configuration values to access in the +> frontend you also need to mark those values as visible using configuration +> schema, either in the app or in your own plugin. For more information, see +> [Defining Configuration](../conf/defining.md). + You can learn more about the local configuration in [Static Configuration in Backstage](../conf/) section. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 6aef4c48c1..9abb68d21b 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 @@ -87,7 +103,7 @@ You may encounter the following error message: Couldn't find any versions for "file-saver" that matches "eligrey-FileSaver.js-1.3.8.tar.gz-art-external" ``` -This is likely because you have a globally configured NPM proxy, which breaks +This is likely because you have a globally configured npm proxy, which breaks the installation of the `material-table` dependency. This is a known issue and being worked on in `material-table`, but for now you can work around it using the following: @@ -161,20 +177,6 @@ the root directory: yarn workspace backend start ``` +Now you're free to hack away on your own Backstage installation! + ### Troubleshooting - -#### Cannot find module - -You may encounter an error similar to below: - -``` -internal/modules/cjs/loader.js:968 - throw err; - ^ - -Error: Cannot find module '../build/Debug/nodegit.node' -``` - -This can occur if an npm dependency is not completely installed. Because some -dependencies run a post-install script, ensure that both your npm and yarn -configs have the `ignore-scripts` flag set to `false`. diff --git a/docs/getting-started/deployment-docker.md b/docs/getting-started/deployment-docker.md new file mode 100644 index 0000000000..69158f9104 --- /dev/null +++ b/docs/getting-started/deployment-docker.md @@ -0,0 +1,238 @@ +--- +id: deployment-docker +title: Docker +description: Documentation on how to deploy Backstage as a Docker image +--- + +This section describes how to build a Backstage App into a deployable Docker +image. It is split into three sections, first covering the host build approach, +which is recommended due its speed and more efficient and often simpler caching. +The second section covers a full multi-stage Docker build, and the last section +covers how to split frontend content into a separate image. + +Something that goes for all of these docker deployment strategies is that they +are stateless, so for a production deployment you will want to set up and +connect to an external PostgreSQL instance where the backend plugins can store +their state, rather than using SQLite. + +### Host Build + +This section describes how to build a Docker image from a Backstage repo with +most of the build happening outside of Docker. This is almost always the faster +approach, as the build steps tend to execute faster, and it's possible to have +more efficient caching of dependencies on the host, where a single change won't +bust the entire cache. + +The required steps in the host build are to install dependencies with +`yarn install`, generate type definitions using `yarn tsc`, and build all +packages with `yarn build`. + +> NOTE: Using `yarn build` to build packages and bundle the backend assumes that +> you have migrated to using `backstage-cli backend:bundle` as your build script +> in the backend package. + +In a CI workflow it might look something like this: + +```bash +yarn install --frozen-lockfile + +# tsc outputs type definitions to dist-types/ in the repo root, which are then consumed by the build +yarn tsc + +# Build all packages and in the end bundle them all up into the packages/backend/dist folder. +yarn build +``` + +Once the host build is complete, we are ready to build our image. We use the +following `Dockerfile`, which is also included when creating a new app with +`@backstage/create-app`: + +```Dockerfile +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` + +FROM node:14-buster-slim + +WORKDIR /app + +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ + +RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +For more details on how the `backend:bundle` command and the `skeleton.tar.gz` +file works, see the +[`backend:bundle` command docs](../cli/commands.md#backendbundle) + +The `Dockerfile` is typically placed at `packages/backend/Dockerfile`, but needs +to be executed with the root of the repo as the build context, in order to get +access to the root `yarn.lock` and `package.json`, along with any other files +that might be needed, such as `.npmrc`. + +In order to speed up the build we can significantly reduce the build context +size using the following `.dockerignore` in the root of the repo: + +```text +.git +node_modules +packages +!packages/backend/dist +plugins +``` + +With the project build and the `.dockerignore` and `Dockerfile` in place, we are +now ready to build the final image. Assuming we're at the root of the repo, we +execute the build like this: + +```bash +docker image build . -f packages/backend/Dockerfile --tag backstage +``` + +To try out the image locally you can run the following: + +```sh +docker run -it -p 7000:7000 backstage +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` + +### Multistage Build + +This section describes how to set up a multi-stage Docker build that builds the +entire project within Docker. This is typically slower than a host build, but is +sometimes desired because Docker in Docker is not available in the build +environment, or due to other requirements. + +The build is split into three different stages, where the first stage finds all +of the `package.json`s that are relevant for the initial install step enabling +us to cache the initial `yarn install` that installs all dependencies. The +second stage executes the build itself, and is similar to the steps we execute +on the host in the host build. The third and final stage then packages it all +together into the final image, and is similar to the `Dockerfile` of the host +build. + +The following `Dockerfile` executes the multi-stage build and should be added to +the repo root: + +```Dockerfile +# Stage 1 - Create yarn install skeleton layer +FROM node:14-buster-slim AS packages + +WORKDIR /app +COPY package.json yarn.lock ./ + +COPY packages packages +COPY plugins plugins + +RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -exec rm -rf {} \+ + +# Stage 2 - Install dependencies and build packages +FROM node:14-buster-slim AS build + +WORKDIR /app +COPY --from=packages /app . + +RUN yarn install --frozen-lockfile --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-slim + +WORKDIR /app + +# Copy the install dependencies from the build stage and context +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 --frozen-lockfile --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +# Copy the built packages from the build stage +COPY --from=build /app/packages/backend/dist/bundle.tar.gz . +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz + +# Copy any other files that we need at runtime +COPY app-config.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +Note that a newly created Backstage app will typically not have a `plugins/` +folder, so you will want to comment that line out. This build also does not work +in the main repo, since the `backstage-cli` which is used for the build doesn't +end up being properly installed. + +To speed up the build when not running in a fresh clone of the repo you should +set up a `.dockerignore`. This one is different than the host build one, because +we want to have access to the source code of all packages for the build, but can +ignore any existing build output or dependencies: + +```text +node_modules +packages/*/dist +packages/*/node_modules +plugins/*/dist +plugins/*/node_modules +``` + +Once you have added both the `Dockerfile` and `.dockerignore` to the root of +your project, run the following to build the container under a specified tag. + +```sh +docker image build -t backstage . +``` + +To try out the image locally you can run the following: + +```sh +docker run -it -p 7000:7000 backstage +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` + +### Separate Frontend + +It is sometimes desirable to serve the frontend separately from the backend, +either from a separate image or for example a static file serving provider. The +first step in doing so is to remove the `app-backend` plugin from the backend +package, which is done as follows: + +1. Delete `packages/backend/src/plugins/app.ts` +2. Remove the following lines from `packages/backend/src/index.ts`: + ```tsx + import app from './plugins/app'; + // ... + const appEnv = useHotMemoize(module, () => createEnv('app')); + // ... + .addRouter('', await app(appEnv)); + ``` +3. Remove the `@backstage/plugin-app-backend` and the app package dependency + (e.g. `app`) from `packages/backend/packages.json`. If you don't remove the + app package dependency the app will still be built and bundled with the + backend. + +Once the `app-backend` is removed from the backend, you can use your favorite +static file serving method for serving the frontend. An example of how to set up +an NGINX image is available in the +[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx/Dockerfile) diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index cfd2e903d9..cc622d00f5 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -4,20 +4,6 @@ title: Other description: Documentation on different ways of Deployment --- -## Deploying Locally - -### Try on Docker - -Run the following commands if you have Docker environment - -```bash -$ yarn install -$ yarn docker-build -$ docker run --rm -it -p 7000:7000 -e APP_ENV=production -e NODE_ENV=development example-backend:latest -``` - -Then open http://localhost/ on your browser. - ## Heroku Deploying to Heroku is relatively easy following these steps. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 7fc80b625c..b946039dfb 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -10,7 +10,7 @@ you're planning to do. Creating a standalone instance makes it simpler to customize the application for your needs whilst staying up to date with the project. You will also depend on -`@backstage` packages from NPM, making the project much smaller. This is the +`@backstage` packages from npm, making the project much smaller. This is the recommended approach if you want to kick the tyres of Backstage or setup your own instance. @@ -19,7 +19,7 @@ general, it's easier to fork and clone this project. That will let you stay up to date with the latest changes, and gives you an easier path to make Pull Requests towards this repo. -### Creating a Standalone App +### Create your Backstage App Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have @@ -46,8 +46,3 @@ look something like this. You can read more about this process in You can read more in our [CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) guide, which can help you get setup with a Backstage development environment. - -### Next steps - -Take a look at the [Running Backstage Locally](./running-backstage-locally.md) -guide to learn how to set up Backstage, and how to develop on the platform. diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 6de9d234e3..fe68937c4e 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -104,7 +104,6 @@ The value of Backstage grows with every new plugin that gets added. Here is a collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins. -- [Development Environment](development-environment.md) - [Create a Backstage Plugin](../plugins/create-a-plugin.md) - [Structure of a Plugin](../plugins/structure-of-a-plugin.md) - [Utility APIs](../api/utility-apis.md) diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000..745cf5f95b --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,26 @@ +--- +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. + +### Authentication Glossary + +This [page](./auth/glossary.md) directs to the terms and phrases related to +authentication and identity section of 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/integrations/github/org.md b/docs/integrations/github/org.md new file mode 100644 index 0000000000..848a306381 --- /dev/null +++ b/docs/integrations/github/org.md @@ -0,0 +1,71 @@ +--- +id: org +title: GitHub Organizational Data +sidebar_label: Org Data +# prettier-ignore +description: Setting up ingestion of organizational data from GitHub +--- + +The Backstage catalog can be set up to ingest organizational data - teams and +users - directly from an organization in GitHub or GitHub Enterprise. The result +is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your org setup. + +## Installation + +The processor that performs the import, `GithubOrgReaderProcessor`, comes +installed with the default setup of Backstage. + +If you replace the set of processors in your installation using that facility of +the catalog builder class, you can import and add it as follows. + +```ts +// Typically in packages/backend/src/plugins/catalog.ts +import { GithubOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + +builder.replaceProcessors( + GithubOrgReaderProcessor.fromConfig(config, { logger }), + // ... +); +``` + +## Configuration + +The following configuration enables an import of the teams and users under the +org `https://github.com/my-org-name` on public GitHub. + +```yaml +catalog: + locations: + - type: github-org + target: https://github.com/my-org-name + processors: + githubOrg: + providers: + - target: https://github.com + apiBaseUrl: https://api.github.com + token: + $env: GITHUB_TOKEN +``` + +Locations point out the specific org(s) you want to import. The `type` of these +locations must be `github-org`, and the `target` must point to the exact URL of +some organization. You can have several such location entries if you want, but +typically you will have just one. + +The processor itself is configured in the other block, under +`catalog.processors.githubOrg`. There may be many providers, each targeting a +specific `target` which is supposed to be the address of the home page of GitHub +or your GitHub Enterprise installation. + +The example above assumes that the backend is started with an environment +variable called `GITHUB_TOKEN` that contains a Personal Access Token. The token +needs to have at least the scopes `read:org`, `read:user`, and `user:email` in +the given `target`. + +If you want to address your own GitHub Enterprise instance, replace occurrences +of `https://github.com` in the configuration above with the address of your +GitHub Enterprise home page, and the `apiBaseUrl` to where your API endpoint +lives - commonly on the form `https:///api/v3`. diff --git a/docs/integrations/google-analytics/installation.md b/docs/integrations/google-analytics/installation.md new file mode 100644 index 0000000000..a0e6f69d13 --- /dev/null +++ b/docs/integrations/google-analytics/installation.md @@ -0,0 +1,24 @@ +--- +id: installation +title: Google Analytics Installation +sidebar_label: Installation +# prettier-ignore +description: Adding Google Analytics to Your App +--- + +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/). + +The default behavior is only to send a pageview hit to Google Analytics. To +record more, look at the developer documentation +[here](https://developers.google.com/analytics/devguides/collection/gtagjs). diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md new file mode 100644 index 0000000000..dea33e4024 --- /dev/null +++ b/docs/integrations/ldap/org.md @@ -0,0 +1,280 @@ +--- +id: org +title: LDAP Organizational Data +sidebar_label: Org Data +# prettier-ignore +description: Setting up ingestion of organizational data from LDAP +--- + +The Backstage catalog can be set up to ingest organizational data - groups and +users - directly from an LDAP compatible service. The result is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your org setup. + +## Installation + +The processor that performs the import, `LdapOrgReaderProcessor`, comes +installed with the default setup of Backstage. + +If you replace the set of processors in your installation using that facility of +the catalog builder class, you can import and add it as follows. + +```ts +// Typically in packages/backend/src/plugins/catalog.ts +import { LdapOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + +builder.replaceProcessors( + LdapOrgReaderProcessor.fromConfig(config, { logger }), + // ... +); +``` + +## Configuration + +The following configuration is a small example of how a setup could look for +importing groups and users from a corporate LDAP server. + +```yaml +catalog: + locations: + - type: ldap-org + target: ldaps://ds.example.net + processors: + ldapOrg: + providers: + - target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: + $env: LDAP_SECRET + users: + dn: ou=people,ou=example,dc=example,dc=net + options: + filter: (uid=*) + map: + description: l + set: + metadata.customField: 'hello' + groups: + dn: ou=access,ou=groups,ou=example,dc=example,dc=net + options: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' +``` + +Locations point out the specific org(s) you want to import. The `type` of these +locations must be `ldap-org`, and the `target` must point to the exact URL +(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can +have several such location entries if you want, but typically you will have just +one. + +The processor itself is configured in the other block, under +`catalog.processors.ldapOrg`. There may be many providers, each targeting a +specific `target` which is supposed to be on the same form as the location +`target`. + +These config blocks have a lot of options in them, so we will describe each +"root" key within the block separately. + +### target + +This is the URL of the targeted server, typically on the form +`ldaps://ds.example.net` for SSL enabled servers or `ldap://ds.example.net` +without SSL. + +### bind + +The bind block specifies how the plugin should bind (essentially, to +authenticate) towards the server. It has the following fields. + +```yaml +dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net +secret: + $env: LDAP_SECRET +``` + +The `dn` is the full LDAP DN (distinguished name) for the user that the plugin +authenticates itself as. At this point, only regular user based authentication +is supported. + +The `secret` is the password of the same user. In this example, it is given in +the form of an environment variable `LDAP_SECRET`, that has to be set when the +backend starts. + +### users + +The `users` block defines the settings that govern the reading and +interpretation of users. Its fields are explained in separate sections below. + +### users.dn + +The DN under which users are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +### users.options + +The search options to use when sending the query to the server, when reading all +users. All of the options are shown below, with their default values, but they +are all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of users that are of actual interest to ingest. For example, you may want + # to filter out disabled users. + filter: (uid=*) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +### users.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +### users.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All of the options are +shown below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the processor will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. + rdn: uid + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: uid + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: mail + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.memberOf field of the entity. + memberOf: memberOf +``` + +### groups + +The `groups` block defines the settings that govern the reading and +interpretation of groups. Its fields are explained in separate sections below. + +### groups.dn + +The DN under which groups are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +### groups.options + +The search options to use when sending the query to the server, when reading all +groups. All of the options are shown below, with their default values, but they +are all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of groups that are of actual interest to ingest. For example, you may want + # to filter out disabled groups. + filter: (&(objectClass=some-group-class)(!(groupType=email))) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +### groups.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +### groups.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All of the options are +shown below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the processor will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. If the target field is optional, such as the display +name, the importer will accept missing attributes and just leave the target +field unset. If the target field is mandatory, such as the name of the entity, +validation will fail if the source attribute is missing. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. This value is copied into a + # well known annotation to be able to query by it later. + rdn: cn + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: cn + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.type field of the entity. + type: groupType + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.parent field of the entity. + memberOf: memberOf + # The name of the attribute that shall be used for the values of + # the spec.children field of the entity. + members: member +``` 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 71612cc5e4..a736126324 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 @@ -47,55 +47,44 @@ guidelines to get started. ### Ongoing work 🚧 -- **[Plugins for managing micro services end-2-end](https://github.com/backstage/backstage/milestone/14)** - Out of the box Backstage will ship with a set of plugins (Overview, CI, API - and Docs) that will demonstrate how a user can manage a micro service and - follow a change all the way out in production. Completing this work will make - it much easier to see how a plugin can be built that integrates with the - Backstage Service Catalog. +- **[Platform stabilization](https://github.com/backstage/backstage/milestone/19)** - + Stabilize the core of Backstage, including its core features, so that the + platform can be depended on for production use. After this, plugins will + require little-to-no maintenance. -- **[Users and teams](https://github.com/backstage/backstage/issues/1807)** - Ownership is a central concept in Backstage. It should be easy to import your - existing organizational data, such as users and groups/teams, into Backstage. - A user the logs into Backstage should see software components owned by the - team(s) they are in. +- **[Kubernetes plugin for service owners](https://github.com/backstage/backstage/issues/2857)** - + Improve native support for Kubernetes, making it easier for service owners to + see and manage their services running in K8s, regardless if that's locally, in + AWS, GCS, Azure, or elsewhere. -- **[Backstage platform is stable](https://github.com/backstage/backstage/milestone/19)** - - The platform APIs and features are stable and can be depended on for - production use. After this plugins will require little to no maintenance. +- **[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. -* **[Improved Kubernetes plugin](https://github.com/backstage/backstage/issues/2857)** - - Native support for Kubernetes, making it easier for developers to see and - manage their services running in k8s. - -- Further improvements to platform documentation. Examples include a Golden Path - for plugin development. +- **[Software Templates V2](https://github.com/backstage/backstage/issues/2771)** - + Expand the templates to make the steps more composable by adding the ability + to add more steps for custom logic, including webhooks and using authorization + from integrations. ### Future work 🔮 -- **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage - deployment available publicly so that people can click around and get a feel - for the product without having to install anything. +- **Golden Path for Plugin Development** - Create an easy, standardized way for + developers to build plugins that will encourage contributions and lead to a + richer ecosystem for everyone. -- **[Global search](https://github.com/backstage/backstage/issues/1499)** - - Extend the basic search available in the Backstage Service Catalog with a - global search experience. Long term this search solution should be extensible, - making it possible for you add custom search results. - -- **[[TechDocs V.2] Stabilization release](https://github.com/backstage/backstage/milestone/17)** - - Platform stability and compatibility improvements. - -- **Additional auth providers** - Backstage should work for most (all!) auth - solutions. Since Backstage can be used by companies regardless of what cloud - (or on prem) you are using we are especially keen to get auth support for - [AWS](https://github.com/backstage/backstage/issues/290), - [Azure](https://github.com/backstage/backstage/issues/348) and others. - -- **[Initial GraphQL API](https://github.com/backstage/backstage/milestone/13)** - - A GraphQL API will open up the rich metadata provided by Backstage in a single +- **[GraphQL API](https://github.com/backstage/backstage/milestone/13)** - A + GraphQL API will open up the rich metadata provided by Backstage in a single query. Plugins can easily query this API as well as extend the model where needed. +- **Inter-Plugin Communication** - **[Under consideration]** Establish more + clearly defined patterns for plugins to communicate. + +- **Improved Access Control** - **[Under consideration]** Provide finer grained + access controls and management for better control of the platform user + experience. + ### Plugins Building and maintaining [plugins](https://backstage.io/plugins) is the work of @@ -110,8 +99,19 @@ Are you missing a plugin for your favorite tool? Please [suggest a new one](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). Chances are that someone will jump in and help build it. +### Community Initiatives 🧑‍🤝‍🧑 + +- [**Backstage Community Sessions**](https://github.com/backstage/community#meetups) - + A monthly meetup for the community to come together to share and learn about + the latest happenings in Backstage. + +- **Backstage Hackathons** - (Coming soon) Open to everyone in our Backstage + community, a celebration of you, the project and building awesome things + together + ### Completed milestones ✅ +- [Deploy a product demo at `demo.backstage.io`](https://demo.backstage.io) - [Kubernetes plugin - v1](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) - [Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage) - [Backstage Design System 💅](https://backstage.io/blog/2020/09/30/backstage-design-system) diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 567e9dcbff..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 @@ -39,28 +38,28 @@ TL;DR: ## Packages -### [`example-app`](https://github.com/backstage/backstage/tree/master/packages/app/) +### `example-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/app/) This is the `packages/app` package, and it serves as an example as well as utility for local development in the main Backstage repo. Stability: `N/A` -### [`example-backend`](https://github.com/backstage/backstage/tree/master/packages/backend/) +### `example-backend` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend/) This is the `packages/backend` package, and it serves as an example as well as utility for local development in the main Backstage repo. Stability: `N/A` -### [`backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common/) +### `backend-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend-common/) A collection of common helpers to be used by both backend plugins, and for constructing backend packages. Stability: `1` -### [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) +### `catalog-client` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) An HTTP client for interacting with the catalog backend. Usable both in frontend and Backend. @@ -68,7 +67,7 @@ and Backend. Stability: `0`. This is a very new addition and we have some immediate changes planned. -### [`catalog-model`](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) +### `catalog-model` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) Contains the core catalog model, and utilities for working with entities. Usable both in frontend and Backend. @@ -77,7 +76,7 @@ Stability: `2`. The catalog model is evolving, but because of the broad usage we want to ensure some stability. -### [`cli`](https://github.com/backstage/backstage/tree/master/packages/cli/) +### `cli` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli/) The main toolchain used for Backstage development. The various CLI commands and options passed to those commands, as well as the environment variables read by @@ -87,27 +86,27 @@ unless it is likely to affect external tooling. Stability: `2` -### [`cli-common`](https://github.com/backstage/backstage/tree/master/packages/cli-common/) +### `cli-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli-common/) Lightweight utilities used by the various Backstage CLIs, not intended for external use. Stability: `N/A` -### [`config`](https://github.com/backstage/backstage/tree/master/packages/config/) +### `config` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config/) Provides the logic and interfaces for reading static configuration. Stability: `2` -### [`config-loader`](https://github.com/backstage/backstage/tree/master/packages/config-loader/) +### `config-loader` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config-loader/) Used to load in static configuration, mainly for use by the CLI and @backstage/backend-common. Stability: `1`. Mainly intended for internal use. -### [`core`](https://github.com/backstage/backstage/tree/master/packages/core/) +### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/) #### Section: React Components @@ -147,51 +146,62 @@ ErrorApi, IdentityApi, the auth APIs, etc. Stability: `1`. Most changes to the core utility APIs will not lead to widespread breaking changes since most apps rely on the default implementations. -### [`core-api`](https://github.com/backstage/backstage/tree/master/packages/core-api/) +### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) The non-visual parts of @backstage/core. Everything in this packages is re-exported from @backstage/core, and this package should not be used directly. Stability: See @backstage/core -### [`create-app`](https://github.com/backstage/backstage/tree/master/packages/create-app/) +### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) The CLI used to scaffold new Backstage projects. Stability: `2` -### [`dev-utils`](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) +### `dev-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) Provides utilities for developing plugins in isolation. Stability: `0`. This package is largely broken and needs updates. -### [`docgen`](https://github.com/backstage/backstage/tree/master/packages/docgen/) +### `docgen` [GitHub](https://github.com/backstage/backstage/tree/master/packages/docgen/) Internal CLI utility for generating API Documentation. Stability: `N/A` -### [`e2e-test`](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) +### `e2e-test` [GitHub](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) Internal CLI utility for running e2e tests. Stability: `N/A` -### [`storybook`](https://github.com/backstage/backstage/tree/master/packages/storybook/) +### `integration` [GitHub](https://github.com/backstage/backstage/tree/master/packages/integration/) + +Provides shared utilities for managing integrations towards different types of +third party systems. This package is currently internal and its functionality +will likely be exposed via separate APIs in the future. + +Some of the functionality in this package is not available elsewhere yes, so if +it's necessary it can be used, but there will be breaking changes. + +Stability: `0` + +### `storybook` [GitHub](https://github.com/backstage/backstage/tree/master/packages/storybook/) Internal storybook build for publishing stories to https://backstage.io/storybook Stability: `N/A` -### [`test-utils`](https://github.com/backstage/backstage/tree/master/packages/test-utils/) +### `test-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils/) Utilities for writing tests for Backstage plugins and apps. Stability: `2` -### [`test-utils-core`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) +### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) Internal testing utilities that are separated out for usage in @backstage/core-api. All exports are re-exported by @backstage/test-utils. This @@ -199,7 +209,7 @@ package should not be depended on directly. Stability: See @backstage/test-utils -### [`theme`](https://github.com/backstage/backstage/tree/master/packages/theme/) +### `theme` [GitHub](https://github.com/backstage/backstage/tree/master/packages/theme/) The core Backstage MUI theme along with customization utilities. @@ -229,21 +239,21 @@ Any plugin that is not listed below is untracked and can generally be considered unstable with a score of `0`. Open a Pull Request if you want your plugin to be added! -### [`api-docs`](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) +### `api-docs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) Components to discover and display API entities as an extension to the catalog plugin. Stability: `0` -### [`app-backend`](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) +### `app-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) A backend plugin that can be used to serve the frontend app and inject configuration. Stability: `2` -### [`auth-backend`](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) +### `auth-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) A backend plugin that implements the backend portion of the various authentication flows used in Backstage. @@ -256,7 +266,7 @@ Stability: `2` Stability: `1` -### [`catalog`](https://github.com/backstage/backstage/tree/master/plugins/catalog/) +### `catalog` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog/) The frontend plugin for the catalog, with the table and building blocks for the entity pages. @@ -264,7 +274,7 @@ entity pages. Stability: `1`. We're planning some work to overhaul how entity pages are constructed. -### [`catalog-backend`](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) +### `catalog-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) The backend API for the catalog, also exposes the processing subsystem for customization of the catalog. Powers the @backstage/plugin-catalog frontend @@ -278,53 +288,53 @@ Stability: `1`. There are plans to remove and rework some endpoints. Stability: `1`. There are plans to rework parts of the Processor interface. -### [`catalog-graphql`](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) +### `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. -### [`explore`](https://github.com/backstage/backstage/tree/master/plugins/explore/) +### `explore` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/explore/) A frontend plugin that introduces the concept of exploring internal and external tooling in an organization. Stability: `0`. Only an example at the moment and not customizable. -### [`graphiql`](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) +### `graphiql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. Stability: `1` -### [`graphql`](https://github.com/backstage/backstage/tree/master/plugins/graphql/) +### `graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphql/) A backend plugin that provides Stability: `0`. Under heavy development and subject to change. -### [`kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) +### `kubernetes` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) The frontend component of the Kubernetes plugin, used to browse and visualize Kubernetes resources. Stability: `1`. -### [`kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) +### `kubernetes-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) The backend component of the Kubernetes plugin, used to fetch Kubernetes resources from clusters and associate them with entities in the Catalog. Stability: `1`. -### [`proxy-backend`](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) +### `proxy-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) A backend plugin used to set up proxying to other endpoints based on static configuration. Stability: `1` -### [`register-component`](https://github.com/backstage/backstage/tree/master/plugins/register-component/) +### `register-component` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/register-component/) A frontend plugin that allows the user to register entity locations in the catalog. @@ -332,14 +342,14 @@ catalog. Stability: `0`. This plugin is likely to be replaced by a generic entity import plugin instead. -### [`scaffolder`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) +### `scaffolder` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) The frontend scaffolder plugin where one can browse templates and initiate scaffolding jobs. Stability: `1` -### [`scaffolder-backend`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) +### `scaffolder-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) The backend scaffolder plugin that provides an implementation for templates in the catalog. @@ -347,38 +357,30 @@ the catalog. Stability: `1`. There is planned work to rework the scaffolder in https://github.com/backstage/backstage/issues/2771. -### [`tech-radar`](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) +### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) Visualize the your company's official guidelines of different areas of software development. Stability: `0` -### [`techdocs`](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) +### `techdocs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) The frontend component of the TechDocs plugin, used to browse technical documentation of entities. Stability: `1` -### [`techdocs-backend`](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) +### `techdocs-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) The backend component of the TechDocs plugin, used to transform and serve TechDocs. Stability: `0` -### [`user-settings`](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) +### `user-settings` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) A frontend plugin that provides a page where the user can tweak various settings. Stability: `1` - -### [`welcome`](https://github.com/backstage/backstage/tree/master/plugins/welcome/) - -A plugin that can be used to welcome the user to Backstage. - -Stability: `0`. This used to be the start page for the example app, but has been -replaced by the catalog plugin. It is still viewable at `/welcome` but may be -removed. diff --git a/docs/overview/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 5439b838bd..ec824a1e11 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -1,15 +1,15 @@ --- 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) [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. So your product teams can ship +your microservices and infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. Backstage unifies all your infrastructure tooling, services, and documentation 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..75993dbc46 --- /dev/null +++ b/docs/plugins/composability.md @@ -0,0 +1,616 @@ +--- +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({ id: 'header-link' }); +``` + +### 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. + +### Optional External Routes + +When creating an `ExternalRouteRef` it is possible to mark it as optional: + +```ts +const headerLinkRouteRef = createExternalRouteRef({ + id: 'header-link', + optional: true, +}); +``` + +An external route that is marked as optional is not required to be bound in the +app, allowing it to be used as a switch for whether a particular link should be +displayed or action should be taken. + +When calling `useRouteRef` with an optional external route, its return signature +is changed to `RouteFunc | undefined`, allowing for logic like this: + +```tsx +const MyComponent = () => { + const headerLink = useRouteRef(headerLinkRouteRef); + + return ( +
+ My Header + {headerLink && External Link} +
+ ); +}; +``` + +### 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..27aec8a31f --- /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 a 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](../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 ahead](#including-in-integrations-config) if you've already got an app. + +### 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 and place 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/index.md b/docs/plugins/index.md index dcef3f995c..32d523b2f5 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -33,4 +33,4 @@ that someone else will pick up the work. If your plugin isn't supposed to live as a standalone page, but rather needs to be presented as a part of a Service Catalog (e.g. a separate tab or a card on an "Overview" tab), then check out -[the instruction](integrating-plugin-into-service-catalog.md). on how to do it. +[the instruction](integrating-plugin-into-service-catalog.md) on how to do it. 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..de6f4bf988 --- /dev/null +++ b/docs/plugins/observability.md @@ -0,0 +1,44 @@ +--- +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 + +See how to install Google Analytics in your app +[here](../integrations/google-analytics/installation.md) + +## 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 7eab61891e..06fae0533e 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -1,16 +1,16 @@ --- id: publishing title: Publishing -description: Documentation on Publishing NPM packages +description: Documentation on Publishing npm packages --- -## NPM +## 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) +npm packages are published through CI/CD in the +[`.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. +npm. ### Creating a new release 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/plugins/testing.md b/docs/plugins/testing.md index e60b5fa8b7..bd6061be19 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -16,15 +16,15 @@ frameworks and libraries like [Mocha](https://mochajs.org/), Running all tests: - yarn test-react + yarn test Running an individual test (e.g. `MyComponent.test.js`): - yarn test-react MyComponent + yarn test MyComponent To run both `MyComponent.test.js` and `MyControl.test.js` suite of tests: - yarn test-react MyCo + yarn test MyCo Note: if `console.logs` are not appearing, run only the individual test you are working on. @@ -52,12 +52,12 @@ render React components. TODO. -# Writing Unit Tests +## Writing Unit Tests The following principles are good guides for determining if you are writing high quality frontend unit tests. -## Bad Unit Test Principle +### Bad Unit Test Principle > No unit test is better than a bad one. @@ -69,7 +69,7 @@ Writing a poor unit test: - Adds to future work by requiring updates to the unit test for irrelevant code changes. -## Input/Output Principle +### Input/Output Principle > A unit test verifies an output matches an expected input. @@ -77,7 +77,7 @@ For backend, this would be that when you provide configuration X, then the object responds with Y. For frontend, this would be that when you provide properties X to a component, then the visual functionality responds with Y. -## Blackbox Principle +### Blackbox Principle > A good unit test does not tell the object how it should do its job but should > only compare inputs to outputs. @@ -86,7 +86,7 @@ Consider a unit test for a form. A good unit test would not test the order of the form fields. Instead, it would verify that the inputs to the form fields lead to a certain backend call when submit is clicked. -## Scalability Principle +### Scalability Principle > Unit test quality is directly proportionate to how much code can change > without having to touch the unit test. @@ -97,7 +97,7 @@ to the code, you have to update the unit test. A good unit test suite allows a lot of flexibility in _how_ the code is written so that future refactoring can occur without having to touch the original unit tests. -## Increasing Complexity Principle +### Increasing Complexity Principle > The ordering of unit tests in a suite should proceed from least specific to > most specific. @@ -116,7 +116,7 @@ throwing an error saying that output was incorrect will lead the next developer into thinking they may have broken the entire functionality of the object rather than simply letting them know they had an invalid input. -## Broken Functionality Principle +### Broken Functionality Principle > Generally, a unit test should not test exactly how the output appears, it > should test that the functionality has an expected _general_ response to an @@ -131,7 +131,7 @@ test a slightly different color on the button the unit test will break. A better unit test would verify that the button's CSS classname is assigned properly on hover or test for something completely different. -## Example: Loading Indicator +### Example: Loading Indicator A classic unit test on frontends is verifying a loading indicator displays when a backend request is being made. @@ -192,11 +192,14 @@ returns a result or displays an error or console message, like so: **`StringUtil ellipsis`** - export function ellipsis(text, maxLength, midCharIx = 0, ellipsis = '...') { - // Do something blackbox. We should not care about the internals, only inputs and outputs. - ... - return someFinalValue; - } +```js +export function ellipsis(text, maxLength, midCharIx = 0, ellipsis = '...') { + // Do something blackbox. We should not care about the internals, + // only inputs and outputs. + ... + return someFinalValue; +} +``` There are four things to test for in a utility function: @@ -207,34 +210,40 @@ There are four things to test for in a utility function: > Handle Invalid Input (handle thrown errors): - it('Throws an error on improper arguments', () => { - expect(() => { - ellipsis(); - }).toThrowError('Expected \'text\' to be defined'); - }); +```js +it('Throws an error on improper arguments', () => { + expect(() => { + ellipsis(); + }).toThrowError("Expected 'text' to be defined"); +}); +``` > Verify default input arguments: - it('Works with defaults', () => { - expect(ellipsis('Hello world', 3)).toBe('Hel...'); - expect(ellipsis('', 3)).toBe(''); - expect(ellipsis('H', 3)).toBe('H'); - expect(ellipsis('Hello', 5)).toBe('Hello'); - }); +```js +it('Works with defaults', () => { + expect(ellipsis('Hello world', 3)).toBe('Hel...'); + expect(ellipsis('', 3)).toBe(''); + expect(ellipsis('H', 3)).toBe('H'); + expect(ellipsis('Hello', 5)).toBe('Hello'); +}); +``` > Verify output for expected input arguments: This is especially true for edge cases! - it('Works with midCharIx', () => { - expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); - expect(ellipsis('', 3, 6)).toBe(''); - expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); - }); +```js +it('Works with midCharIx', () => { + expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); + expect(ellipsis('', 3, 6)).toBe(''); + expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); +}); +``` ## Non-React Classes -Testing a Javascript object which is _not_ a React component follows a lot of +Testing a JavaScript object which is _not_ a React component follows a lot of the same principles as testing objects in other languages. ### API Testing Principles @@ -243,7 +252,7 @@ Testing an API involves verifying four things: 1. Invalid inputs are caught before being sent to the server. 2. Valid inputs translate into a valid browser request. -3. Server response is translated into an expected Javascript object. +3. Server response is translated into an expected JavaScript object. 4. Server errors are handled gracefully. ### Mocking API Calls @@ -265,7 +274,7 @@ export { }; ``` -**`./\_\_mocks\_\_/MyApi.js`** +**`./__mocks__/MyApi.js`** ```js export { @@ -372,4 +381,4 @@ IDE. In most cases, we have found that using `console.log` works well. Note: if your console.logs are not being displayed, focus your specific unit -test from the command line by running them like so `yarn test-react MyTest`. +test from the command line by running them like so `yarn test MyTest`. diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index bcea80e26b..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'); }, @@ -27,7 +27,7 @@ To inspect the state of a feature flag inside your plugin, you can use the `FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; 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/AlertApi.md b/docs/reference/utility-apis/AlertApi.md index 4116e964ae..8d1851cc44 100644 --- a/docs/reference/utility-apis/AlertApi.md +++ b/docs/reference/utility-apis/AlertApi.md @@ -1,7 +1,7 @@ # AlertApi The AlertApi type is defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L29). +[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AlertApi.ts#L29). The following Utility API implements this type: [alertApiRef](./README.md#alert) @@ -38,7 +38,7 @@ export type AlertMessage = { Defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L19). +[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AlertApi.ts#L19). Referenced by: [post](#post), [alert\$](#alert). @@ -67,7 +67,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [alert\$](#alert). @@ -86,7 +86,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -109,6 +109,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md index 0f5c095825..a662b4cb70 100644 --- a/docs/reference/utility-apis/AppThemeApi.md +++ b/docs/reference/utility-apis/AppThemeApi.md @@ -1,7 +1,7 @@ # AppThemeApi The AppThemeApi type is defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:56](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L56). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:56](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L56). The following Utility API implements this type: [appThemeApiRef](./README.md#apptheme) @@ -81,7 +81,7 @@ export type AppTheme = { Defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:25](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L25). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:25](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L25). Referenced by: [getInstalledThemes](#getinstalledthemes). @@ -92,7 +92,7 @@ export type BackstagePalette = Palette & Palette Defined at -[packages/theme/src/types.ts:74](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L74). +[packages/theme/src/types.ts:74](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L74). Referenced by: [BackstageTheme](#backstagetheme). @@ -107,7 +107,7 @@ export interface BackstageTheme extends Theme { Defined at -[packages/theme/src/types.ts:81](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L81). +[packages/theme/src/types.ts:81](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L81). Referenced by: [AppTheme](#apptheme). @@ -136,7 +136,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [activeThemeId\$](#activethemeid). @@ -155,7 +155,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -170,7 +170,7 @@ export type PageTheme = { Defined at -[packages/theme/src/types.ts:103](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L103). +[packages/theme/src/types.ts:103](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L103). Referenced by: [BackstageTheme](#backstagetheme). @@ -183,7 +183,7 @@ export type PageThemeSelector = { Defined at -[packages/theme/src/types.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L77). +[packages/theme/src/types.ts:77](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L77). Referenced by: [BackstageTheme](#backstagetheme). @@ -243,7 +243,7 @@ type PaletteAdditions = { Defined at -[packages/theme/src/types.ts:23](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L23). +[packages/theme/src/types.ts:23](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L23). Referenced by: [BackstagePalette](#backstagepalette). @@ -266,6 +266,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index dafdb7d55f..80a40d427e 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -1,7 +1,7 @@ # BackstageIdentityApi The BackstageIdentityApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L134). +[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L134). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -70,7 +74,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getBackstageIdentity](#getbackstageidentity). @@ -91,6 +95,6 @@ export type BackstageIdentity = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L147). +[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L147). Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md index 2ae047a700..c5b20610e7 100644 --- a/docs/reference/utility-apis/Config.md +++ b/docs/reference/utility-apis/Config.md @@ -1,7 +1,7 @@ # Config The Config type is defined at -[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L32). The following Utility API implements this type: [configApiRef](./README.md#config) @@ -140,7 +140,7 @@ export type Config = { Defined at -[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L32). Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig), [getConfigArray](#getconfigarray), @@ -153,7 +153,7 @@ export type JsonArray = JsonValue[] Defined at -[packages/config/src/types.ts:18](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L18). +[packages/config/src/types.ts:18](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L18). Referenced by: [JsonValue](#jsonvalue). @@ -164,7 +164,7 @@ export type JsonObject = { [key in string]?: JsonValue Defined at -[packages/config/src/types.ts:17](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L17). +[packages/config/src/types.ts:17](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L17). Referenced by: [JsonValue](#jsonvalue). @@ -181,7 +181,7 @@ export type JsonValue = Defined at -[packages/config/src/types.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L19). +[packages/config/src/types.ts:19](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L19). Referenced by: [get](#get), [getOptional](#getoptional), [JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config). diff --git a/docs/reference/utility-apis/DiscoveryApi.md b/docs/reference/utility-apis/DiscoveryApi.md index ac278fdd97..0d888c1daa 100644 --- a/docs/reference/utility-apis/DiscoveryApi.md +++ b/docs/reference/utility-apis/DiscoveryApi.md @@ -1,7 +1,7 @@ # DiscoveryApi The DiscoveryApi type is defined at -[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). +[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). The following Utility API implements this type: [discoveryApiRef](./README.md#discovery) @@ -12,7 +12,7 @@ The following Utility API implements this type: Returns the HTTP base backend URL for a given plugin, without a trailing slash. -This method must always be called just before making a request. as opposed to +This method must always be called just before making a request, as opposed to fetching the URL when constructing an API client. That is to ensure that more flexible routing patterns can be supported. diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md index 93f4f9cd48..1aaecdb47e 100644 --- a/docs/reference/utility-apis/ErrorApi.md +++ b/docs/reference/utility-apis/ErrorApi.md @@ -1,7 +1,7 @@ # ErrorApi The ErrorApi type is defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). +[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). The following Utility API implements this type: [errorApiRef](./README.md#error) @@ -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.
@@ -41,7 +41,7 @@ type Error = {
 
Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). +[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). Referenced by: [post](#post), [error\$](#error). @@ -58,7 +58,7 @@ export type ErrorContext = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). +[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). Referenced by: [post](#post), [error\$](#error). @@ -87,7 +87,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [error\$](#error). @@ -106,7 +106,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -129,6 +129,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md index 529d2ac5dc..1e8979c02e 100644 --- a/docs/reference/utility-apis/FeatureFlagsApi.md +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -1,7 +1,7 @@ # FeatureFlagsApi The FeatureFlagsApi type is defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:60](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L60). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:60](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L60). The following Utility API implements this type: [featureFlagsApiRef](./README.md#featureflags) @@ -68,7 +68,7 @@ export type FeatureFlag = { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L31). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L31). Referenced by: [registerFlag](#registerflag), [getRegisteredFlags](#getregisteredflags). @@ -83,7 +83,7 @@ export enum FeatureFlagState { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:36](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L36). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:36](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L36). Referenced by: [FeatureFlagsSaveOptions](#featureflagssaveoptions). @@ -108,6 +108,6 @@ export type FeatureFlagsSaveOptions = { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:44](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L44). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:44](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L44). Referenced by: [save](#save). diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md index a88aaf0b8a..aa2a4dd9a0 100644 --- a/docs/reference/utility-apis/IdentityApi.md +++ b/docs/reference/utility-apis/IdentityApi.md @@ -1,7 +1,7 @@ # IdentityApi The IdentityApi type is defined at -[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). +[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). The following Utility API implements this type: [identityApiRef](./README.md#identity) @@ -76,6 +76,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md index 79b55812ef..9af6b3bb53 100644 --- a/docs/reference/utility-apis/OAuthApi.md +++ b/docs/reference/utility-apis/OAuthApi.md @@ -1,7 +1,7 @@ # OAuthApi The OAuthApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L67). +[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L67). The following Utility APIs implement this type: @@ -15,8 +15,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + ## Members ### getAccessToken() @@ -82,7 +86,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getAccessToken](#getaccesstoken). @@ -108,6 +112,6 @@ export type OAuthScope = string | string[] Defined at -[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L38). +[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L38). Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md index f1c2311ce9..5f521cf288 100644 --- a/docs/reference/utility-apis/OAuthRequestApi.md +++ b/docs/reference/utility-apis/OAuthRequestApi.md @@ -1,7 +1,7 @@ # OAuthRequestApi The OAuthRequestApi type is defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). The following Utility API implements this type: [oauthRequestApiRef](./README.md#oauthrequest) @@ -73,7 +73,7 @@ export type AuthProvider = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). Referenced by: [AuthRequesterOptions](#authrequesteroptions), [PendingAuthRequest](#pendingauthrequest). @@ -97,7 +97,7 @@ export type AuthRequester<AuthResponse> = ( Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). Referenced by: [createAuthRequester](#createauthrequester). @@ -122,7 +122,7 @@ export type AuthRequesterOptions<AuthResponse> = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). Referenced by: [createAuthRequester](#createauthrequester). @@ -151,7 +151,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [authRequest\$](#authrequest). @@ -170,7 +170,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -205,7 +205,7 @@ export type PendingAuthRequest = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). Referenced by: [authRequest\$](#authrequest). @@ -228,6 +228,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md index eaea530708..6d05af6189 100644 --- a/docs/reference/utility-apis/OpenIdConnectApi.md +++ b/docs/reference/utility-apis/OpenIdConnectApi.md @@ -1,7 +1,7 @@ # OpenIdConnectApi The OpenIdConnectApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L99). +[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L99). The following Utility APIs implement this type: @@ -13,8 +13,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + ## Members ### getIdToken() @@ -66,6 +70,6 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md index 76d04045e8..1a2f94d031 100644 --- a/docs/reference/utility-apis/ProfileInfoApi.md +++ b/docs/reference/utility-apis/ProfileInfoApi.md @@ -1,7 +1,7 @@ # ProfileInfoApi The ProfileInfoApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L117). +[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L117). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -67,7 +71,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getProfile](#getprofile). @@ -95,6 +99,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md index 1ef5b6197e..aefbb4b925 100644 --- a/docs/reference/utility-apis/README.md +++ b/docs/reference/utility-apis/README.md @@ -12,7 +12,7 @@ Used to report alerts and forward them to the app Implemented type: [AlertApi](./AlertApi.md) ApiRef: -[alertApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L41) +[alertApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AlertApi.ts#L41) ### appTheme @@ -21,7 +21,7 @@ API Used to configure the app theme, and enumerate options Implemented type: [AppThemeApi](./AppThemeApi.md) ApiRef: -[appThemeApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L80) +[appThemeApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L80) ### auth0Auth @@ -32,7 +32,7 @@ Implemented types: [OpenIdConnectApi](./OpenIdConnectApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[auth0AuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L275) +[auth0AuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L275) ### config @@ -41,7 +41,7 @@ Used to access runtime configuration Implemented type: [Config](./Config.md) ApiRef: -[configApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) +[configApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ConfigApi.ts#L25) ### discovery @@ -50,7 +50,7 @@ Provides service discovery of backend plugins Implemented type: [DiscoveryApi](./DiscoveryApi.md) ApiRef: -[discoveryApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) +[discoveryApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) ### error @@ -59,7 +59,7 @@ Used to report errors and forward them to the app Implemented type: [ErrorApi](./ErrorApi.md) ApiRef: -[errorApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) +[errorApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) ### featureFlags @@ -68,7 +68,7 @@ Used to toggle functionality in features across Backstage Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) ApiRef: -[featureFlagsApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L83) +[featureFlagsApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L83) ### githubAuth @@ -79,7 +79,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[githubAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L232) +[githubAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L232) ### gitlabAuth @@ -90,7 +90,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[gitlabAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L262) +[gitlabAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L262) ### googleAuth @@ -102,7 +102,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[googleAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L215) +[googleAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L215) ### identity @@ -111,7 +111,7 @@ Provides access to the identity of the signed in user Implemented type: [IdentityApi](./IdentityApi.md) ApiRef: -[identityApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) +[identityApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/IdentityApi.ts#L53) ### microsoftAuth @@ -123,7 +123,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[microsoftAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L289) +[microsoftAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L289) ### oauth2 @@ -135,7 +135,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oauth2ApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L303) +[oauth2ApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L303) ### oauthRequest @@ -144,7 +144,19 @@ An API for implementing unified OAuth flows in Backstage Implemented type: [OAuthRequestApi](./OAuthRequestApi.md) ApiRef: -[oauthRequestApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) +[oauthRequestApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) + +### oidcAuth + +Example of how to use oidc custom provider + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) + +ApiRef: +[oidcAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L317) ### oktaAuth @@ -156,7 +168,19 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oktaAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L245) +[oktaAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L245) + +### oneloginAuth + +Provides authentication towards OneLogin APIs and identities + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) + +ApiRef: +[oneloginAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L338) ### samlAuth @@ -166,7 +190,7 @@ Implemented types: [ProfileInfoApi](./ProfileInfoApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[samlAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L317) +[samlAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L331) ### storage @@ -175,4 +199,4 @@ Provides the ability to store data which is unique to the user Implemented type: [StorageApi](./StorageApi.md) ApiRef: -[storageApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L68) +[storageApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L68) diff --git a/docs/reference/utility-apis/SessionApi.md b/docs/reference/utility-apis/SessionApi.md index 7d271558d6..4b584d92c8 100644 --- a/docs/reference/utility-apis/SessionApi.md +++ b/docs/reference/utility-apis/SessionApi.md @@ -1,7 +1,7 @@ # SessionApi The SessionApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L190). +[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L190). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -77,7 +81,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [sessionState\$](#sessionstate). @@ -96,7 +100,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -112,7 +116,7 @@ export enum SessionState { Defined at -[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L182). +[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L182). Referenced by: [sessionState\$](#sessionstate). @@ -135,6 +139,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md index 6c5595d3d8..3247d28f60 100644 --- a/docs/reference/utility-apis/StorageApi.md +++ b/docs/reference/utility-apis/StorageApi.md @@ -1,7 +1,7 @@ # StorageApi The StorageApi type is defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L31). The following Utility API implements this type: [storageApiRef](./README.md#storage) @@ -79,7 +79,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -98,7 +98,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -144,7 +144,7 @@ export interface StorageApi { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L31). Referenced by: [forBucket](#forbucket). @@ -158,7 +158,7 @@ export type StorageValueChange<T = any> = { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L21). +[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L21). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -181,6 +181,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md index c25e087e57..55a92da571 100644 --- a/docs/support/project-structure.md +++ b/docs/support/project-structure.md @@ -1,13 +1,13 @@ --- 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 -different files and folders. This document aims to clarify what purpose of those -files and folders are. +different files and folders. This document aims to clarify the purpose of those +files and folders. ## General purpose files and folders @@ -25,32 +25,29 @@ the code. Standard GitHub folder. It contains - amongst other things - our workflow definitions and templates. Worth noting is the [styles](https://github.com/backstage/backstage/tree/master/.github/styles) - folder which is used for a markdown spellchecker. + sub-folder which is used for a markdown spellchecker. - [`.yarn/`](https://github.com/backstage/backstage/tree/master/.yarn) - Backstage ships with it's own `yarn` implementation. This allows us to have better control over our `yarn.lock` file and hopefully avoid problems due to yarn versioning differences. -- [`docker/`](https://github.com/backstage/backstage/tree/master/docker) - Files - related to our root Dockerfile. We are planning to refactor this, so expect - this folder to be moved in the future. - - [`contrib/`](https://github.com/backstage/backstage/tree/master/contrib) - - Collection of examples or resources provided by the community. We really + Collection of examples or resources contributed by the community. We really appreciate contributions in here and encourage them being kept up to date. - [`docs/`](https://github.com/backstage/backstage/tree/master/docs) - This is - where we keep all of our documentation Markdown files. These ends up on - http://backstage.io/docs. Just keep in mind that changes to the + where we keep all of our documentation Markdown files. These end up on + https://backstage.io/docs. Just keep in mind that changes to the [`sidebars.json`](https://github.com/backstage/backstage/blob/master/microsite/sidebars.json) file may be needed as sections are added/removed. - [`.editorconfig`](https://github.com/backstage/backstage/tree/master/.editorconfig) - - A configuration file used by most common code editors. + A configuration file used by most common code editors. Learn more at + [EditorConfig.org](https://editorconfig.org/). - [`.imgbotconfig`](https://github.com/backstage/backstage/tree/master/.imgbotconfig) - - Configuration for a [bot](https://imgbot.net/) + Configuration for a [bot](https://imgbot.net/) which helps reduce image sizes. ## Monorepo packages @@ -107,16 +104,16 @@ are separated out into their own folder, see further down. diff, create-plugins and more. In the early days of this project, we started out with calling tools directly - such as `eslint` - through `package.json`. But as it was tricky to have a good development experience around that when we - change named tooling, we opted for wrapping those in our own cli. That way + change named tooling, we opted for wrapping those in our own CLI. That way everything looks the same in `package.json`. Much like [react-scripts](https://github.com/facebook/create-react-app/tree/master/packages/react-scripts). - [`cli-common/`](https://github.com/backstage/backstage/tree/master/packages/cli-common) - This package mainly handles path resolving. It is a separate package to reduce bugs in - [cli](https://github.com/backstage/backstage/tree/master/packages/cli). We + [CLI](https://github.com/backstage/backstage/tree/master/packages/cli). We also want as few dependencies as possible to reduce download time when running - the cli which is another reason this is a separate package. + the CLI which is another reason this is a separate package. - [`config/`](https://github.com/backstage/backstage/tree/master/packages/config) - The way we read configuration data. This package can take a bunch of config @@ -143,14 +140,6 @@ are separated out into their own folder, see further down. detail that we try to hide from our users, and no one should have to depend on it directly. -- [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) - - This package contains specific testing facilities used when testing - `core-api`. - -- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) - - This package contains more general purpose testing facilities for testing a - Backstage App. - - [`create-app/`](https://github.com/backstage/backstage/tree/master/packages/create-app) - An CLI to specifically scaffold a new Backstage App. It does so by using a [template](https://github.com/backstage/backstage/tree/master/packages/create-app/templates/default-app). @@ -161,22 +150,34 @@ are separated out into their own folder, see further down. - [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) - Uses the - [Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API) + [TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) to read out definitions and generate documentation for it. - [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) - - Another CLI that can be run to try out what would happen if you built all the - packages, publish them, created a new app, and the run it. CI uses this for + Another CLI that can be run to try out what would happen if you build all the + packages, publish them, create a new app, and then run them. CI uses this for e2e-tests. +- [`integration/`](https://github.com/backstage/backstage/tree/master/packages/integration) - + Common functionalities of integrations like GitHub, GitLab, etc. + - [`storybook/`](https://github.com/backstage/backstage/tree/master/packages/storybook) - - This folder contains only the storybook config. Stories are within the core - package. The Backstage Storybook is found - [here](https://backstage.io/storybook) + This folder contains only the Storybook config which helps visualize our + reusable React components. Stories are within the core package, and are + published in the [Backstage Storybook](https://backstage.io/storybook). -- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) +- [`techdocs-common/`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) - + Common functionalities for TechDocs, to be shared between + [techdocs-backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) + plugin and [techdocs-cli](https://github.com/backstage/techdocs-cli). -- [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) +- [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) - + This package contains specific testing facilities used when testing + `core-api`. + +- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) - + This package contains more general purpose testing facilities for testing a + Backstage App. - [`theme/`](https://github.com/backstage/backstage/tree/master/packages/theme) - Holds the Backstage Theme. @@ -192,10 +193,10 @@ We can categorize plugins into three different types; **Frontend**, **Backend** and **GraphQL**. We differentiate these types of plugins when we name them, with a dash-suffix. `-backend` means it’s a backend plugin and so on. -One reason for splitting a plugin is because of to it's dependencies. Another -reason is for clear separation of concerns. +One reason for splitting a plugin is because of its dependencies. Another reason +is for clear separation of concerns. -Take a look at our [Plugin Gallery](https://backstage.io/plugins) or browse +Take a look at our [Plugin Marketplace](https://backstage.io/plugins) or browse through the [`plugins/`](https://github.com/backstage/backstage/tree/master/plugins) folder. @@ -208,7 +209,7 @@ monorepo setup. This folder contains the source code for backstage.io. It is built with [Docusaurus](https://docusaurus.io/). This folder is not part of the monorepo due to dependency reasons. Look at the - [README](https://github.com/backstage/backstage/blob/master/microsite/README.md) + [microsite README](https://github.com/backstage/backstage/blob/master/microsite/README.md) for instructions on how to run it locally. ## Root files specifically used by the `app` @@ -218,8 +219,8 @@ Some of these files may be subject to be moved out of the root sometime in the future. - [`.npmrc`](https://github.com/backstage/backstage/tree/master/.npmrc) - It's - common for companies to have their own npm registry, this files makes sure - that this folder use the public registry. + common for companies to have their own npm registry, and this file makes sure + that this folder always uses the public registry. - [`.vale.ini`](https://github.com/backstage/backstage/tree/master/.vale.ini) - [Spell checker](https://github.com/errata-ai/vale) for Markdown files. diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 664d4b77d3..adefdfa73f 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -22,7 +22,7 @@ music and wants to have a theme tune for every service in Backstage. Sam built a Spotify plugin for Backstage that allows service owners to define a theme tune for their service. The theme tune plays whenever a user visits the -service page in Backstage. The plugin is published to NPM and available for any +service page in Backstage. The plugin is published to npm and available for any organization to easily install and add to their Backstage installation. # 1. A New Plugin 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 216ff9659f..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 @@ -81,13 +80,13 @@ import { useApi } from '@backstage/core-api'; _from inline:_ ```tsx -const ExampleComponent: FC<{}> = () => ( ... ) +const ExampleComponent = () => ( ... ) ``` _to block:_ ```tsx -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { return ( ... @@ -135,7 +134,7 @@ changes, let's start by wiping this component clean. 1. Replace everything in the file with the following: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -143,11 +142,11 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { return
Nothing to see yet
; }; @@ -223,7 +222,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, diff --git a/microsite/blog/2020-03-16-announcing-backstage.md b/microsite/blog/2020-03-16-announcing-backstage.md index 5824b76a57..5094de7b37 100644 --- a/microsite/blog/2020-03-16-announcing-backstage.md +++ b/microsite/blog/2020-03-16-announcing-backstage.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md index f4f62e2cf7..2c2b81d64d 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.md +++ b/microsite/blog/2020-03-18-what-is-backstage.md @@ -1,6 +1,6 @@ --- title: What the heck is Backstage anyway? -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-04-06-lighthouse-plugin.md b/microsite/blog/2020-04-06-lighthouse-plugin.md index b8fd68e783..dcdb4b78e5 100644 --- a/microsite/blog/2020-04-06-lighthouse-plugin.md +++ b/microsite/blog/2020-04-06-lighthouse-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Lighthouse for Backstage -author: Paul Marbach +author: Paul Marbach, Spotify authorURL: http://twitter.com/fastfrwrd authorImageURL: https://pbs.twimg.com/profile_images/1224058798958088192/JPxS8uzR_400x400.jpg --- diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index 85f40dd9ea..65102c919f 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -1,6 +1,6 @@ --- title: How to quickly set up Backstage -author: Marcus Eide +author: Marcus Eide, Spotify authorURL: https://github.com/marcuseide authorImageURL: https://secure.gravatar.com/avatar/20223f1e03673c7c1e6282fbebaf6942 --- diff --git a/microsite/blog/2020-05-14-tech-radar-plugin.md b/microsite/blog/2020-05-14-tech-radar-plugin.md index 80c1eb8b5a..b78cf9004f 100644 --- a/microsite/blog/2020-05-14-tech-radar-plugin.md +++ b/microsite/blog/2020-05-14-tech-radar-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Tech Radar for Backstage -author: Bilawal Hameed +author: Bilawal Hameed, Spotify authorURL: http://twitter.com/bilawalhameed authorImageURL: https://avatars0.githubusercontent.com/bih --- diff --git a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md index 68e3f903c7..3e3d8a537a 100644 --- a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md +++ b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md @@ -1,6 +1,6 @@ --- title: Weaveworks’ COVID-19 app uses Backstage UI -author: Jeff Feng +author: Jeff Feng, Spotify authorURL: https://github.com/fengypants authorImageURL: https://avatars2.githubusercontent.com/u/46946747 --- diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 103750dcc6..520a2a5f10 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -1,6 +1,6 @@ --- title: Starting Phase 2: The Service Catalog -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md index e50d7a6d47..4519f16d96 100644 --- a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md +++ b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md @@ -1,6 +1,6 @@ --- title: Backstage Service Catalog released in alpha -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund image: https://backstage.io/blog/assets/6/header.png --- diff --git a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md index 9d41a21d05..ce778c9dc2 100644 --- a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md +++ b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md @@ -1,6 +1,6 @@ --- title: How to enable authentication in Backstage using Passport -author: Lee Mills +author: Lee Mills, Spotify authorURL: https://github.com/leemills83 authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4 --- diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index bd6ae8eeeb..afda20e499 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage Software Templates -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index ceab17d7be..98fbad57c5 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -1,12 +1,12 @@ --- title: Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage -author: Gary Niemen +author: Gary Niemen, Spotify 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-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md index 8fc459cb62..48a67e878c 100644 --- a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md +++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md @@ -1,6 +1,6 @@ --- title: Backstage has been accepted into the CNCF Sandbox -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-09-30-backstage-design-system.md b/microsite/blog/2020-09-30-backstage-design-system.md index a1d087f755..fc227ea5d3 100644 --- a/microsite/blog/2020-09-30-backstage-design-system.md +++ b/microsite/blog/2020-09-30-backstage-design-system.md @@ -1,6 +1,6 @@ --- title: How to design for Backstage (even if you’re not a designer) -author: Kat Zhou +author: Kat Zhou, Spotify authorURL: http://twitter.com/katherinemzhou --- diff --git a/microsite/blog/2020-09-30-plugin-marketplace.md b/microsite/blog/2020-09-30-plugin-marketplace.md index 10928112b8..f4e9b749e2 100644 --- a/microsite/blog/2020-09-30-plugin-marketplace.md +++ b/microsite/blog/2020-09-30-plugin-marketplace.md @@ -1,6 +1,6 @@ --- title: The Plugin Marketplace is open -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index 15647f8418..d265698694 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -1,12 +1,12 @@ --- title: New Cost Insights plugin: The engineer’s solution to taming cloud costs -author: Janisa Anandamohan +author: Janisa Anandamohan, Spotify 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/2020-12-22-stability-index.md b/microsite/blog/2020-12-22-stability-index.md new file mode 100644 index 0000000000..95074a13a2 --- /dev/null +++ b/microsite/blog/2020-12-22-stability-index.md @@ -0,0 +1,42 @@ +--- +title: Announcing the Backstage Stability Index +author: Patrik Oldsberg, Spotify +authorURL: https://github.com/Rugvip +--- + +**TL;DR** Backstage is heading out of alpha and moving onto the path to stable releases and an eventual version 1.0. As the community and ecosystem continue to grow at an increasing rate, we want to provide a solid foundation for everyone building things in, with, and around Backstage. So, today we’re introducing the [Stability Index](https://backstage.io/docs/overview/stability-index) — a simple way to find out how likely (or unlikely) a specific package or plugin inside Backstage might be updated with major changes. By indicating the reliability of key features and APIs, this quick reference will help contributors and adopters better plan and coordinate their development efforts going forward. + +![Animation cycling between stability index scores](assets/2020-12-22/stability-index-hero.gif) + + + +Backstage has a great and growing community of users and contributors. With so much valuable feedback, this inevitably means that sometimes we will go back and revise design decisions that introduce breaking changes. Furthermore, as we continue to ship new features and systems, we’ll continue this cycle of iterating, gathering feedback, and iterating again before reaching a stable API. + +This rapid evolution can create uncertainty around which parts of the project are considered stable and which are not. Regardless of whether you're contributing to the project directly, setting up your own Backstage, or building plugins, you will be using various parts of the project that vary in stability. While many API surfaces of Backstage are reasonably solid and are unlikely to see any big breaking changes, others are more unstable or experimental. + +## The Stability Index + +In order to tackle the problem of uncertainty, and help align contributors, we have recently introduced a Stability Index. Inspired by a [similar concept with the same name in Node.js](https://nodejs.org/docs/latest-v4.x/api/documentation.html#documentation_stability_index), it’s a score assigned to subsets of the project, indicating the level of maturity of the API and the commitment to backwards compatibility. However, because of the current phase of the project, we have used a slightly different implementation. Rather than the score indicating a perceived stability, a higher score is instead a commitment to providing a smoother upgrade path for users, both through better documentation and backwards compatibility. Importantly, the Stability Index does not supersede [semantic versioning](https://semver.org/) (or semver), meaning we will still adhere to semver and only do breaking changes in minor releases as long as we are on 0.x. + +You can find more details about the scores on the [Stability Index](https://backstage.io/docs/overview/stability-index) page, but the following is a TL;DR of the 0–3 scores: + +- **0** — There's a changelog entry. +- **1** — There's a migration guide. +- **2** — Two weeks of deprecation. +- **3** — Three months of deprecation. + +The Stability Index has been tailored for the phase that the project is currently in, and we do not intend to keep it in place in its current form forever. It is, for example, likely that we in the future will increase the deprecation time limits. + +## Staying up to date + +In addition to the Stability Index, we have also recently adopted a weekly release cadence where we cut new releases every Thursday. Along with the two-week deprecation period of many of the API surfaces in Backstage, there is an excellent opportunity to form a routine to update your own Backstage projects either weekly or biweekly. + +In order to make the update process easier, we’ve added a new command to the Backstage CLI that allows you to easily update your project to use the latest Backstage release. The command is called `version:bump` and is executed in the root of your project using the CLI. You can see what it looks like in action below. + +![Output of the `backstage-cli` `version:bump` command](assets/2020-12-22/versions-bump.png) + +## Impact + +The Stability Index has already been active for a couple of weeks, and we have immediately seen an impact on the consideration and care taken when working in parts of the project that we have committed to keeping more stable. Both members of the core maintainer team and contributors from the community have taken care to roll out updates responsibly, with examples such as [PR #3524](https://github.com/backstage/backstage/pull/3524) and [PR #3465](https://github.com/backstage/backstage/pull/3465). + +The core team is working hard to bring more stability to broader parts of Backstage, and engineering it in a way that allows for long-term evolution. There are more learnings to be discovered as we continue evolving Backstage, but we are confident that the Stability Index will allow for a clearer path and a smoother ride for everyone involved! 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/2020-12-22/stability-index-hero.gif b/microsite/blog/assets/2020-12-22/stability-index-hero.gif new file mode 100644 index 0000000000..6459d241e2 Binary files /dev/null and b/microsite/blog/assets/2020-12-22/stability-index-hero.gif differ diff --git a/microsite/blog/assets/2020-12-22/versions-bump.png b/microsite/blog/assets/2020-12-22/versions-bump.png new file mode 100644 index 0000000000..2a8d624687 Binary files /dev/null and b/microsite/blog/assets/2020-12-22/versions-bump.png differ 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/core/Footer.js b/microsite/core/Footer.js index 6ce3c3dd84..1471d7b4d1 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -54,6 +54,12 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} + + + Spotify Engineering Blog + + Spotify for Developers + GitHub +

+ Made with ❤️  at Spotify +

{this.props.config.copyright}

); diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml new file mode 100644 index 0000000000..ca753f682a --- /dev/null +++ b/microsite/data/plugins/argo-cd.yaml @@ -0,0 +1,12 @@ +--- +title: Argo CD +author: roadie.io +authorUrl: https://roadie.io +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 +npmPackageName: '@roadiehq/backstage-plugin-argo-cd' +tags: + - cd + - ci diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml index f325c0df8b..cc3d59d188 100644 --- a/microsite/data/plugins/aws-lambda.yaml +++ b/microsite/data/plugins/aws-lambda.yaml @@ -2,7 +2,7 @@ title: AWS Lambda author: roadie.io authorUrl: https://roadie.io -category: Monitoring +category: Infrastructure description: View AWS Lambda functions for your components in Backstage. documentation: https://roadie.io/backstage/plugins/aws-lambda iconUrl: https://roadie.io/images/logos/lambda.png 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/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml index 8c1ea0be0a..9a1777754a 100644 --- a/microsite/data/plugins/firebase-functions.yaml +++ b/microsite/data/plugins/firebase-functions.yaml @@ -2,7 +2,7 @@ title: Firebase Functions author: roadie.io authorUrl: https://roadie.io/ -category: Monitoring +category: Infrastructure description: View Firebase Functions details for your service in Backstage. documentation: https://roadie.io/backstage/plugins/firebase-functions iconUrl: https://roadie.io/images/logos/firebase.png diff --git a/microsite/data/plugins/fossa.yaml b/microsite/data/plugins/fossa.yaml new file mode 100644 index 0000000000..c49e89c994 --- /dev/null +++ b/microsite/data/plugins/fossa.yaml @@ -0,0 +1,9 @@ +--- +title: FOSSA +author: SDA SE +authorUrl: https://sda.se/ +category: Quality +description: View FOSSA license compliance of your components in Backstage. +documentation: https://github.com/backstage/backstage/blob/master/plugins/fossa/README.md +iconUrl: https://avatars0.githubusercontent.com/u/9543448?s=400&v=4 +npmPackageName: '@backstage/plugin-fossa' 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/kubernetes.yaml b/microsite/data/plugins/kubernetes.yaml deleted file mode 100644 index 3134edf858..0000000000 --- a/microsite/data/plugins/kubernetes.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Kubernetes -author: Spotify -authorUrl: https://github.com/spotify -category: Kubernetes -description: Surfaces components in a Kubernetes container orchestration environment into the Backstage catalog. -documentation: https://github.com/backstage/backstage/tree/master/plugins/kubernetes -iconUrl: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png -npmPackageName: '@backstage/plugin-kubernetes' -tags: - - kubernetes - - k8s 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/pager-duty.yaml b/microsite/data/plugins/pager-duty.yaml new file mode 100644 index 0000000000..d0006c09ec --- /dev/null +++ b/microsite/data/plugins/pager-duty.yaml @@ -0,0 +1,13 @@ +--- +title: PagerDuty +author: Spotify +authorUrl: https://github.com/spotify +category: Monitoring +description: PagerDuty offers a simple way to identify any active incidents for an entity and the escalation policy. +documentation: https://github.com/backstage/backstage/tree/master/plugins/pagerduty +iconUrl: https://avatars2.githubusercontent.com/u/766800?s=200&v=4 +npmPackageName: '@backstage/plugin-pagerduty' +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/splunk-on-call.yaml b/microsite/data/plugins/splunk-on-call.yaml new file mode 100644 index 0000000000..15ceb86a1b --- /dev/null +++ b/microsite/data/plugins/splunk-on-call.yaml @@ -0,0 +1,14 @@ +--- +title: Splunk On-Call +author: Spotify +authorUrl: https://github.com/spotify +category: Monitoring +description: Splunk On-Call offers a simple way to identify incidents and escalation policies. +documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call +iconUrl: img/splunk-black-white-bg.png +npmPackageName: '@backstage/plugin-splunk-on-call' +tags: + - monitoring + - errors + - alerting + - splunk 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 6be0579475..ea16fd8a32 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -11,12 +11,13 @@ "publish-gh-pages": "docusaurus-publish", "write-translations": "docusaurus-write-translations", "version": "docusaurus-version", - "rename-version": "docusaurus-rename-version" + "rename-version": "docusaurus-rename-version", + "verify:sidebars": "node ./scripts/verify-sidebars" }, "devDependencies": { "@spotify/prettier-config": "^9.0.0", - "docusaurus": "^2.0.0-alpha.66", - "js-yaml": "^3.14.0", + "docusaurus": "^2.0.0-alpha.70", + "js-yaml": "^4.0.0", "prettier": "^2.2.1" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index 63c15ab698..86cb6c786d 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -17,22 +17,20 @@ const Background = props => { - See us in action. + See us in action - To illustrate the potential of Backstage, we’re showing you{' '} - - how we use it here at Spotify + To explore the UI and basic features of Backstage firsthand, go + to: demo.backstage.io. + (Tip: click “All” to view all the example components in the + service catalog.) + + + Watch the videos below to get an introduction to Backstage and to + see how we use different plugins to customize{' '} + + our internal version of Backstage at Spotify - . The videos below feature the custom plugins and templates that - we built for our internal version of Backstage. You can use - Backstage to build the developer portal you want — integrating the - tools that you use in your own infrastructure ecosystem. (Or get - started by building an open source plugin for Backstage that - everyone can use, like our{' '} - - Lighthouse Plugin - - .) + . @@ -104,9 +102,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 > @@ -139,9 +137,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 0422bfab63..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() { @@ -27,8 +28,8 @@ class Index extends React.Component { Powered by a centralized service catalog, Backstage restores - order to your infrastructure. So your product teams can ship - high-quality code quickly — without compromising autonomy. + order to your infrastructure and enables your product teams to + ship high-quality code quickly — without compromising autonomy. + + + 🎉 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 b1854135dc..9ee54173f1 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -16,7 +16,7 @@ const { const pluginsDirectory = require('path').join(process.cwd(), 'data/plugins'); const pluginMetadata = fs .readdirSync(pluginsDirectory) - .map(file => yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8'))) + .map(file => yaml.load(fs.readFileSync(`./data/plugins/${file}`, 'utf8'))) .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); const truncate = text => text.length > 170 ? text.substr(0, 170) + '...' : text; @@ -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/scripts/verify-sidebars.js b/microsite/scripts/verify-sidebars.js new file mode 100755 index 0000000000..a82780135f --- /dev/null +++ b/microsite/scripts/verify-sidebars.js @@ -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. + */ + +/** + * This script relies on docusaurus internals. It can be replaced in case + * docusaurus adds proper validation of the sidebar items. + */ + +let metadata; +try { + metadata = require('docusaurus/lib/core/metadata.js'); +} catch (error) { + console.log('❌ Run `yarn build` before running `yarn verify:sidebars`'); + process.exit(1); +} + +const errors = []; +const ids = Object.keys(metadata); +for (let id of ids) { + const { next, previous } = metadata[id]; + + if (next && !ids.includes(next)) { + errors.push(`Next ${next} does not exist in ${id}.`); + } + + if (previous && !ids.includes(previous)) { + errors.push(`Previous ${previous} does not exist in ${id}.`); + } +} + +if (errors.length) { + for (const error of errors) { + console.log(`❌ ${error}`); + } + process.exit(1); +} + +console.log('✅ All sidebar links are correct.'); diff --git a/microsite/sidebars.json b/microsite/sidebars.json index e60d9c520e..8b5c0e5f0c 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -13,8 +13,7 @@ "Getting Started": [ "getting-started/index", "getting-started/running-backstage-locally", - "getting-started/installation", - "getting-started/development-environment", + "getting-started/contributors", "getting-started/create-an-app", { "type": "subcategory", @@ -28,13 +27,24 @@ "type": "subcategory", "label": "Deployment", "ids": [ + "getting-started/deployment-docker", "getting-started/deployment-k8s", "getting-started/deployment-helm", "getting-started/deployment-other" ] } ], + "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", @@ -65,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", @@ -74,11 +92,32 @@ "features/techdocs/concepts", "features/techdocs/architecture", "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" ] } ], + "Integrations": [ + { + "type": "subcategory", + "label": "GitHub", + "ids": ["integrations/github/org"] + }, + { + "type": "subcategory", + "label": "LDAP", + "ids": ["integrations/ldap/org"] + }, + { + "type": "subcategory", + "label": "Google Analytics", + "ids": ["integrations/google-analytics/installation"] + } + ], "Plugins": [ "plugins/index", "plugins/existing-plugins", @@ -86,6 +125,7 @@ "plugins/plugin-development", "plugins/structure-of-a-plugin", "plugins/integrating-plugin-into-service-catalog", + "plugins/composability", { "type": "subcategory", "label": "Backends and APIs", @@ -106,7 +146,8 @@ "ids": [ "plugins/publishing", "plugins/publish-private", - "plugins/add-to-marketplace" + "plugins/add-to-marketplace", + "plugins/observability" ] } ], @@ -138,8 +179,7 @@ "api/utility-apis", "reference/utility-apis/README", "reference/createPlugin", - "reference/createPlugin-feature-flags", - "reference/createPlugin-router" + "reference/createPlugin-feature-flags" ] }, { @@ -163,10 +203,12 @@ "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 517cac2496..51354343d8 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -8,11 +8,8 @@ // See https://docusaurus.io/docs/site-config for all the possible // site configuration options. -// List of projects/orgs using your project for the users page. -const users = []; - const siteConfig = { - title: 'Backstage', // Title for your website. + title: 'Backstage Service Catalog and Developer Platform', // Title for your website. tagline: 'An open platform for building developer portals', url: 'https://backstage.io', // Your website URL cname: 'backstage.io', @@ -72,11 +69,6 @@ const siteConfig = { navGroupSubcategoryTitleColor: '#9e9e9e', }, - /* Colors for syntax highlighting */ - highlight: { - theme: 'dark', - }, - // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`, @@ -90,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 c07285ed8c..c54e0101d3 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -48,6 +48,19 @@ h6 { color: $textColor; } +summary { + color: $textColor; + cursor: pointer; +} + +h2:hover .hash-link { + opacity: 1; +} + +.hash-link { + fill: white; +} + /* body elements */ p, ul, @@ -1022,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; @@ -1053,6 +1115,10 @@ code { font-size: 115%; } +.post pre { + color: #e4e4e4; +} + .Block__GIF { width: 190px; height: auto; diff --git a/microsite/static/img/splunk-black-white-bg.png b/microsite/static/img/splunk-black-white-bg.png new file mode 100644 index 0000000000..76eb7ef0f8 Binary files /dev/null and b/microsite/static/img/splunk-black-white-bg.png differ 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 22efeb7a24..898fd69ad2 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -9,59 +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.10.4", "@babel/compat-data@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" - integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== - dependencies: - browserslist "^4.12.0" - invariant "^2.2.4" - semver "^5.5.0" +"@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== -"@babel/core@^7.9.0": - version "7.11.6" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651" - integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg== +"@babel/core@^7.12.3": + 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.11.6" - "@babel/helper-module-transforms" "^7.11.0" - "@babel/helpers" "^7.10.4" - "@babel/parser" "^7.11.5" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.11.5" - "@babel/types" "^7.11.5" + "@babel/generator" "^7.12.10" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helpers" "^7.12.5" + "@babel/parser" "^7.12.10" + "@babel/template" "^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.11.5", "@babel/generator@^7.11.6": - version "7.11.6" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620" - integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA== +"@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.11.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" @@ -71,54 +66,34 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.10.4", "@babel/helper-builder-react-jsx-experimental@^7.11.5": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.11.5.tgz#4ea43dd63857b0a35cd1f1b161dc29b43414e79f" - integrity sha512-Vc4aPJnRZKWfzeCBsqTBnzulVNjABVdahSPhtdMD3Vs80ykx4a87jTHtF/VR+alSrDmNvat7l13yrRHauGcHVw== +"@babel/helper-compilation-targets@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" - "@babel/types" "^7.11.5" - -"@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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" - integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ== - dependencies: - "@babel/compat-data" "^7.10.4" - browserslist "^4.12.0" - invariant "^2.2.4" - levenary "^1.1.1" + "@babel/compat-data" "^7.12.5" + "@babel/helper-validator-option" "^7.12.1" + browserslist "^4.14.5" semver "^5.5.0" -"@babel/helper-create-class-features-plugin@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" - integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== +"@babel/helper-create-class-features-plugin@^7.12.1": + version "7.12.1" + 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" - "@babel/helper-member-expression-to-functions" "^7.10.5" + "@babel/helper-member-expression-to-functions" "^7.12.1" "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" + "@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== +"@babel/helper-create-regexp-features-plugin@^7.12.1": + version "7.12.7" + 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" - "@babel/helper-regex" "^7.10.4" - regexpu-core "^4.7.0" + regexpu-core "^4.7.1" "@babel/helper-define-map@^7.10.4": version "7.10.5" @@ -130,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" @@ -159,117 +134,115 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" - integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q== +"@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.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.11.0" + "@babel/types" "^7.12.7" -"@babel/helper-module-imports@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" - integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== +"@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": + version "7.12.5" + 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.10.4" + "@babel/types" "^7.12.5" -"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" - integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== +"@babel/helper-module-transforms@^7.12.1": + version "7.12.1" + 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.10.4" - "@babel/helper-replace-supers" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-simple-access" "^7.12.1" "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/helper-validator-identifier" "^7.10.4" "@babel/template" "^7.10.4" - "@babel/types" "^7.11.0" + "@babel/traverse" "^7.12.1" + "@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.10.4": - version "7.11.4" - resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz#4474ea9f7438f18575e30b0cac784045b402a12d" - integrity sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA== +"@babel/helper-remap-async-to-generator@^7.12.1": + version "7.12.1" + 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" "@babel/helper-wrap-function" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-replace-supers@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf" - integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A== +"@babel/helper-replace-supers@^7.12.1": + 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.10.4" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" - integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== +"@babel/helper-simple-access@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== dependencies: - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-skip-transparent-expression-wrappers@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" - integrity sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q== +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + 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.11.0" + "@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", "@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" "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helpers@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" - integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== +"@babel/helpers@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" "@babel/highlight@^7.0.0", "@babel/highlight@^7.10.4": version "7.10.4" @@ -280,116 +253,116 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.10.4", "@babel/parser@^7.11.5": - 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.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.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" - integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg== +"@babel/plugin-proposal-async-generator-functions@^7.12.1": + 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.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" -"@babel/plugin-proposal-class-properties@^7.10.4", "@babel/plugin-proposal-class-properties@^7.8.3": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" - integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== +"@babel/plugin-proposal-class-properties@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-dynamic-import@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e" - integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ== +"@babel/plugin-proposal-dynamic-import@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-dynamic-import" "^7.8.0" -"@babel/plugin-proposal-export-namespace-from@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" - integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg== +"@babel/plugin-proposal-export-namespace-from@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db" - integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw== +"@babel/plugin-proposal-json-strings@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-json-strings" "^7.8.0" -"@babel/plugin-proposal-logical-assignment-operators@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8" - integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q== +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" - integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== +"@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" -"@babel/plugin-proposal-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06" - integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA== +"@babel/plugin-proposal-numeric-separator@^7.12.7": + version "7.12.7" + 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" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" - integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== +"@babel/plugin-proposal-object-rest-spread@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-transform-parameters" "^7.10.4" + "@babel/plugin-transform-parameters" "^7.12.1" -"@babel/plugin-proposal-optional-catch-binding@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" - integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g== +"@babel/plugin-proposal-optional-catch-binding@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" - integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== +"@babel/plugin-proposal-optional-chaining@^7.12.7": + version "7.12.7" + 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" - "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-private-methods@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" - integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== +"@babel/plugin-proposal-private-methods@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^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== +"@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.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.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-async-generators@^7.8.0": @@ -399,10 +372,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" - integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== +"@babel/plugin-syntax-class-properties@^7.12.1": + version "7.12.1" + 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" @@ -427,10 +400,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.10.4.tgz#39abaae3cbf710c4373d8429484e6ba21340166c" - integrity sha512-KCg9mio9jwiARCB7WAcQ7Y1q+qicILjoK8LP/VkPkEKaf5dkaZZK1EcTe91a3JJlZ3qy6L5s9X52boEYi8DM9g== +"@babel/plugin-syntax-jsx@^7.12.1": + version "7.12.1" + 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" @@ -476,346 +449,328 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d" - integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ== +"@babel/plugin-syntax-top-level-await@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" - integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== +"@babel/plugin-transform-arrow-functions@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37" - integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ== +"@babel/plugin-transform-async-to-generator@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-module-imports" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" -"@babel/plugin-transform-block-scoped-functions@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8" - integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA== +"@babel/plugin-transform-block-scoped-functions@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.11.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" - integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew== +"@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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" - integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== +"@babel/plugin-transform-classes@^7.12.1": + version "7.12.1" + 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" "@babel/helper-define-map" "^7.10.4" "@babel/helper-function-name" "^7.10.4" "@babel/helper-optimise-call-expression" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb" - integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw== +"@babel/plugin-transform-computed-properties@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" - integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== +"@babel/plugin-transform-destructuring@^7.12.1": + version "7.12.1" + 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.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== +"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.12.1" + 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.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-duplicate-keys@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" - integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA== +"@babel/plugin-transform-duplicate-keys@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e" - integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw== +"@babel/plugin-transform-exponentiation-operator@^7.12.1": + version "7.12.1" + 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" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-for-of@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" - integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== +"@babel/plugin-transform-for-of@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7" - integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg== +"@babel/plugin-transform-function-name@^7.12.1": + version "7.12.1" + 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" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-literals@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c" - integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ== +"@babel/plugin-transform-literals@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7" - integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw== +"@babel/plugin-transform-member-expression-literals@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1" - integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw== +"@babel/plugin-transform-modules-amd@^7.12.1": + version "7.12.1" + 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.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" - integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== +"@babel/plugin-transform-modules-commonjs@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-simple-access" "^7.12.1" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" - integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw== +"@babel/plugin-transform-modules-systemjs@^7.12.1": + version "7.12.1" + 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" - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-identifier" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e" - integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA== +"@babel/plugin-transform-modules-umd@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6" - integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA== +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" -"@babel/plugin-transform-new-target@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888" - integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw== +"@babel/plugin-transform-new-target@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894" - integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ== +"@babel/plugin-transform-object-super@^7.12.1": + version "7.12.1" + 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" - "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" -"@babel/plugin-transform-parameters@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" - integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw== - dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-property-literals@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0" - integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g== +"@babel/plugin-transform-parameters@^7.12.1": + version "7.12.1" + 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-react-display-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.10.4.tgz#b5795f4e3e3140419c3611b7a2a3832b9aef328d" - integrity sha512-Zd4X54Mu9SBfPGnEcaGcOrVAYOtjT2on8QZkLKEq1S/tHexG39d9XXGZv19VfRrDjPJzFmPfTAqOQS1pfFOujw== +"@babel/plugin-transform-property-literals@^7.12.1": + version "7.12.1" + 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-jsx-development@^7.10.4": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.11.5.tgz#e1439e6a57ee3d43e9f54ace363fb29cefe5d7b6" - integrity sha512-cImAmIlKJ84sDmpQzm4/0q/2xrXlDezQoixy3qoz1NJeZL/8PRon6xZtluvr4H4FzwlDGI5tCcFupMnXGtr+qw== - dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.11.5" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" - -"@babel/plugin-transform-react-jsx-self@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369" - integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg== +"@babel/plugin-transform-react-display-name@^7.12.1": + version "7.12.1" + 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-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-jsx-source@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.10.5.tgz#34f1779117520a779c054f2cdd9680435b9222b4" - integrity sha512-wTeqHVkN1lfPLubRiZH3o73f4rfon42HpgxUSs86Nc+8QIcm/B9s8NNVXu/gwGcOyd7yDib9ikxoDLxJP0UiDA== +"@babel/plugin-transform-react-jsx-development@^7.12.7": + 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-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" + "@babel/plugin-transform-react-jsx" "^7.12.12" -"@babel/plugin-transform-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz#673c9f913948764a4421683b2bef2936968fddf2" - integrity sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A== +"@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-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.10.4" + "@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.10.4" + "@babel/plugin-syntax-jsx" "^7.12.1" + "@babel/types" "^7.12.12" -"@babel/plugin-transform-react-pure-annotations@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.10.4.tgz#3eefbb73db94afbc075f097523e445354a1c6501" - integrity sha512-+njZkqcOuS8RaPakrnR9KvxjoG1ASJWpoIv/doyWngId88JoFlPlISenGXjrVacZUIALGUr6eodRs1vmPnF23A== +"@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" + integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-regenerator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63" - integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw== +"@babel/plugin-transform-regenerator@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd" - integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ== +"@babel/plugin-transform-reserved-words@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" - integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== +"@babel/plugin-transform-shorthand-properties@^7.12.1": + version "7.12.1" + 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.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" - integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== +"@babel/plugin-transform-spread@^7.12.1": + version "7.12.1" + 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" - "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" -"@babel/plugin-transform-sticky-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" - integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - -"@babel/plugin-transform-template-literals@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" - integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-typeof-symbol@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc" - integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA== +"@babel/plugin-transform-sticky-regex@^7.12.7": + version "7.12.7" + 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-unicode-escapes@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" - integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg== +"@babel/plugin-transform-template-literals@^7.12.1": + version "7.12.1" + 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-unicode-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8" - integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A== +"@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-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/polyfill@^7.8.7": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.11.5.tgz#df550b2ec53abbc2ed599367ec59e64c7a707bb5" - integrity sha512-FunXnE0Sgpd61pKSj2OSOs1D44rKTD3pGOfGilZ6LGrrIH0QEtJlTjqOqdF8Bs98JmjfGhni2BBkTfv9KcKJ9g== +"@babel/plugin-transform-unicode-escapes@^7.12.1": + version "7.12.1" + 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.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" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/polyfill@^7.12.1": + version "7.12.1" + 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.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" - integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== +"@babel/preset-env@^7.12.1": + 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.11.0" - "@babel/helper-compilation-targets" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" + "@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/plugin-proposal-async-generator-functions" "^7.10.4" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-dynamic-import" "^7.10.4" - "@babel/plugin-proposal-export-namespace-from" "^7.10.4" - "@babel/plugin-proposal-json-strings" "^7.10.4" - "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" - "@babel/plugin-proposal-numeric-separator" "^7.10.4" - "@babel/plugin-proposal-object-rest-spread" "^7.11.0" - "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" - "@babel/plugin-proposal-optional-chaining" "^7.11.0" - "@babel/plugin-proposal-private-methods" "^7.10.4" - "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" + "@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" + "@babel/plugin-proposal-export-namespace-from" "^7.12.1" + "@babel/plugin-proposal-json-strings" "^7.12.1" + "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-numeric-separator" "^7.12.7" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.7" + "@babel/plugin-proposal-private-methods" "^7.12.1" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" - "@babel/plugin-syntax-class-properties" "^7.10.4" + "@babel/plugin-syntax-class-properties" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.0" @@ -825,45 +780,42 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" - "@babel/plugin-syntax-top-level-await" "^7.10.4" - "@babel/plugin-transform-arrow-functions" "^7.10.4" - "@babel/plugin-transform-async-to-generator" "^7.10.4" - "@babel/plugin-transform-block-scoped-functions" "^7.10.4" - "@babel/plugin-transform-block-scoping" "^7.10.4" - "@babel/plugin-transform-classes" "^7.10.4" - "@babel/plugin-transform-computed-properties" "^7.10.4" - "@babel/plugin-transform-destructuring" "^7.10.4" - "@babel/plugin-transform-dotall-regex" "^7.10.4" - "@babel/plugin-transform-duplicate-keys" "^7.10.4" - "@babel/plugin-transform-exponentiation-operator" "^7.10.4" - "@babel/plugin-transform-for-of" "^7.10.4" - "@babel/plugin-transform-function-name" "^7.10.4" - "@babel/plugin-transform-literals" "^7.10.4" - "@babel/plugin-transform-member-expression-literals" "^7.10.4" - "@babel/plugin-transform-modules-amd" "^7.10.4" - "@babel/plugin-transform-modules-commonjs" "^7.10.4" - "@babel/plugin-transform-modules-systemjs" "^7.10.4" - "@babel/plugin-transform-modules-umd" "^7.10.4" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" - "@babel/plugin-transform-new-target" "^7.10.4" - "@babel/plugin-transform-object-super" "^7.10.4" - "@babel/plugin-transform-parameters" "^7.10.4" - "@babel/plugin-transform-property-literals" "^7.10.4" - "@babel/plugin-transform-regenerator" "^7.10.4" - "@babel/plugin-transform-reserved-words" "^7.10.4" - "@babel/plugin-transform-shorthand-properties" "^7.10.4" - "@babel/plugin-transform-spread" "^7.11.0" - "@babel/plugin-transform-sticky-regex" "^7.10.4" - "@babel/plugin-transform-template-literals" "^7.10.4" - "@babel/plugin-transform-typeof-symbol" "^7.10.4" - "@babel/plugin-transform-unicode-escapes" "^7.10.4" - "@babel/plugin-transform-unicode-regex" "^7.10.4" + "@babel/plugin-syntax-top-level-await" "^7.12.1" + "@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.11" + "@babel/plugin-transform-classes" "^7.12.1" + "@babel/plugin-transform-computed-properties" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-dotall-regex" "^7.12.1" + "@babel/plugin-transform-duplicate-keys" "^7.12.1" + "@babel/plugin-transform-exponentiation-operator" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-function-name" "^7.12.1" + "@babel/plugin-transform-literals" "^7.12.1" + "@babel/plugin-transform-member-expression-literals" "^7.12.1" + "@babel/plugin-transform-modules-amd" "^7.12.1" + "@babel/plugin-transform-modules-commonjs" "^7.12.1" + "@babel/plugin-transform-modules-systemjs" "^7.12.1" + "@babel/plugin-transform-modules-umd" "^7.12.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" + "@babel/plugin-transform-new-target" "^7.12.1" + "@babel/plugin-transform-object-super" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-property-literals" "^7.12.1" + "@babel/plugin-transform-regenerator" "^7.12.1" + "@babel/plugin-transform-reserved-words" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@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.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.11.5" - browserslist "^4.12.0" - core-js-compat "^3.6.2" - invariant "^2.2.2" - levenary "^1.1.1" + "@babel/types" "^7.12.11" + core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": @@ -877,23 +829,21 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.9.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" - integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== +"@babel/preset-react@^7.12.5": + 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.10.4" - "@babel/plugin-transform-react-jsx" "^7.10.4" - "@babel/plugin-transform-react-jsx-development" "^7.10.4" - "@babel/plugin-transform-react-jsx-self" "^7.10.4" - "@babel/plugin-transform-react-jsx-source" "^7.10.4" - "@babel/plugin-transform-react-pure-annotations" "^7.10.4" + "@babel/plugin-transform-react-display-name" "^7.12.1" + "@babel/plugin-transform-react-jsx" "^7.12.10" + "@babel/plugin-transform-react-jsx-development" "^7.12.7" + "@babel/plugin-transform-react-pure-annotations" "^7.12.1" -"@babel/register@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/register/-/register-7.11.5.tgz#79becf89e0ddd0fba8b92bc279bc0f5d2d7ce2ea" - integrity sha512-CAml0ioKX+kOAvBQDHa/+t1fgOt3qkTIz0TrRtRAT6XY0m5qYZXR85k6/sLCNPMGhYDlCFHCYuU0ybTJbvlC6w== +"@babel/register@^7.12.1": + 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" @@ -902,42 +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== +"@babel/template@^7.10.4", "@babel/template@^7.12.7": + version "7.12.7" + 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.10.4" - "@babel/types" "^7.10.4" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3" - integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ== +"@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.11.5" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.11.5" - "@babel/types" "^7.11.5" + "@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.11.5", "@babel/types@^7.4.4", "@babel/types@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" - integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== +"@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" @@ -965,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" @@ -1000,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" @@ -1054,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: @@ -1075,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" @@ -1093,6 +1037,11 @@ argparse@^1.0.10, argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -1174,15 +1123,20 @@ asynckit@^0.4.0: resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" 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" @@ -1212,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" @@ -1242,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" @@ -1404,15 +1358,16 @@ browserslist@4.7.0: electron-to-chromium "^1.3.247" node-releases "^1.1.29" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5: - 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" + caniuse-lite "^1.0.30001165" + colorette "^1.2.1" + electron-to-chromium "^1.3.621" + escalade "^3.1.1" + node-releases "^1.1.67" buffer-alloc-unsafe@^1.1.0: version "1.1.0" @@ -1443,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" @@ -1488,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" @@ -1535,10 +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.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" @@ -1718,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" @@ -1845,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.6.2: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c" - integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng== +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.8.5" + 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" @@ -1948,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: @@ -1962,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" @@ -2040,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" @@ -2067,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" @@ -2163,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== @@ -2233,20 +2189,20 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.66: - version "2.0.0-alpha.66" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.66.tgz#4dece48b838f773c4c973d9fe2546dd0bf2637cf" - integrity sha512-/HmRt3hEqpvZRdR2JRYKpwNUgalO14exxuwQu2vXW5eYzcKbQ922+3SxG7mF+LE8f7KOmgY+A3eS3SEyq+S3XA== +docusaurus@^2.0.0-alpha.70: + version "2.0.0-alpha.378053ac5" + resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" + integrity sha512-+NM1NrJKYcmHYiMQ/4b5ew9QDbLW3QI/ii2lCs/gpEZhtU0FVnl1RLC3FUO41xgOhth3A8M5G5ChzlPuNzxjug== dependencies: - "@babel/core" "^7.9.0" - "@babel/plugin-proposal-class-properties" "^7.8.3" - "@babel/plugin-proposal-object-rest-spread" "^7.9.0" - "@babel/polyfill" "^7.8.7" - "@babel/preset-env" "^7.9.0" - "@babel/preset-react" "^7.9.4" - "@babel/register" "^7.9.0" - "@babel/traverse" "^7.9.0" - "@babel/types" "^7.9.0" + "@babel/core" "^7.12.3" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/polyfill" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.5" + "@babel/register" "^7.12.1" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.6" autoprefixer "^9.7.5" babylon "^6.18.0" chalk "^3.0.0" @@ -2256,10 +2212,10 @@ docusaurus@^2.0.0-alpha.66: cssnano "^4.1.10" escape-string-regexp "^2.0.0" express "^4.17.1" - feed "^4.0.0" - fs-extra "^8.1.0" + feed "^4.2.1" + fs-extra "^9.0.1" gaze "^1.1.3" - github-slugger "^1.2.1" + github-slugger "^1.3.0" glob "^7.1.6" highlight.js "^9.16.2" imagemin "^6.0.0" @@ -2267,12 +2223,12 @@ docusaurus@^2.0.0-alpha.66: imagemin-jpegtran "^6.0.0" imagemin-optipng "^6.0.0" imagemin-svgo "^7.0.0" - lodash "^4.17.15" + lodash "^4.17.20" markdown-toc "^1.2.0" mkdirp "^0.5.1" - portfinder "^1.0.25" + portfinder "^1.0.28" postcss "^7.0.23" - prismjs "^1.17.1" + prismjs "^1.22.0" react "^16.8.4" react-dev-utils "^9.1.0" react-dom "^16.8.4" @@ -2307,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" @@ -2335,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" @@ -2399,10 +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.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" @@ -2432,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" @@ -2450,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" @@ -2476,10 +2450,10 @@ 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.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-html@~1.0.3: version "1.0.3" @@ -2729,7 +2703,7 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -feed@^4.0.0: +feed@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" integrity sha512-l28KKcK1J/u3iq5dRDmmoB2p7dtBfACC2NqJh4dI2kFptxH0asfjmOfcxqh5Sv8suAlVa73gZJ4REY5RrafVvg== @@ -2940,14 +2914,15 @@ fs-constants@^1.0.0: resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@^8.1.0: - version "8.1.0" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== +fs-extra@^9.0.1: + version "9.0.1" + 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" graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" + jsonfile "^6.0.1" + universalify "^1.0.0" fs.realpath@^1.0.0: version "1.0.0" @@ -2975,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" @@ -3033,7 +3017,7 @@ gifsicle@^4.0.0: execa "^1.0.0" logalot "^2.0.0" -github-slugger@^1.2.1: +github-slugger@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== @@ -3226,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== @@ -3282,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" @@ -3346,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" @@ -3366,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" @@ -3472,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" @@ -3508,17 +3492,10 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.2, invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.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" @@ -3566,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" @@ -3583,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" @@ -3683,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" @@ -3708,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" @@ -3729,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== @@ -3782,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== @@ -3802,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" @@ -3865,14 +3854,21 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.1: - version "3.14.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== +js-yaml@^3.13.1, js-yaml@^3.8.1: + version "3.14.1" + 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" esprima "^4.0.0" +js-yaml@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -3932,10 +3928,12 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" optionalDependencies: graceful-fs "^4.1.6" @@ -3987,18 +3985,6 @@ lazy-cache@^2.0.2: dependencies: set-getter "^0.1.0" -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -levenary@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" - integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== - dependencies: - leven "^3.1.0" - list-item@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz#0c65d00e287cb663ccb3cb3849a77e89ec268a56" @@ -4155,7 +4141,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@~4.17.10: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20, lodash@~4.17.10: version "4.17.20" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -4173,7 +4159,7 @@ longest@^1.0.0: resolved "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.1.0, 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== @@ -4276,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" @@ -4346,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" @@ -4410,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" @@ -4457,10 +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.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" @@ -4549,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== @@ -4566,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" @@ -4592,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: @@ -4896,7 +4888,7 @@ pkg-up@2.0.0: dependencies: find-up "^2.1.0" -portfinder@^1.0.25: +portfinder@^1.0.28: version "1.0.28" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== @@ -4911,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" @@ -5148,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" @@ -5186,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" @@ -5209,10 +5202,10 @@ prettier@^2.2.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== -prismjs@^1.17.1: - version "1.21.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" - integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw== +prismjs@^1.22.0: + version "1.23.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== optionalDependencies: clipboard "^2.0.0" @@ -5364,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" @@ -5374,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" @@ -5384,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" @@ -5470,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" @@ -5494,10 +5487,10 @@ 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== +regexpu-core@^4.7.1: + version "4.7.1" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== dependencies: regenerate "^1.4.0" regenerate-unicode-properties "^8.2.0" @@ -5602,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: @@ -5652,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" @@ -5955,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" @@ -6032,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" @@ -6198,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" @@ -6358,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" @@ -6438,10 +6432,15 @@ uniqs@^2.0.0: resolved "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^1.0.0: + version "1.0.0" + 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.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" @@ -6510,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 1086259710..031fb56059 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,7 +16,7 @@ nav: - Getting started: - Getting Started: 'getting-started/index.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' - - Local development: 'getting-started/development-environment.md' + - Contributors: 'getting-started/contributors.md' - Demo deployment: https://backstage-demo.roadie.io - Production deployments: - Create an App: 'getting-started/create-an-app.md' @@ -24,6 +24,7 @@ nav: - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - Deployment scenarios: + - Docker: 'getting-started/deployment-docker.md' - Kubernetes: 'getting-started/deployment-k8s.md' - Kubernetes and Helm: 'getting-started/deployment-helm.md' - Other: 'getting-started/deployment-other.md' @@ -48,20 +49,36 @@ 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' - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - 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' + - Integrations: + - GitHub: + - Org Data: 'integrations/github/org.md' + - LDAP: + - Org Data: 'integrations/ldap/org.md' + - Google Analytics: + - Installation: 'integrations/google-analytics/installation.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' @@ -69,8 +86,9 @@ nav: - Testing: - Overview: 'plugins/testing.md' - Publishing: - - Open source and NPM: 'plugins/publishing.md' + - 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' @@ -93,7 +111,6 @@ nav: - Utilities: 'api/utility-apis.md' - createPlugin: 'reference/createPlugin.md' - createPlugin-feature-flags: 'reference/createPlugin-feature-flags.md' - - createPlugin-router: 'reference/createPlugin-router.md' - Backend APIs: - Backend: 'api/backend.md' - Tutorials: @@ -109,8 +126,10 @@ 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' - - Contribute: '../CONTRIBUTING.md' + - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' + - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md' - Support: - 'support/support.md' - 'support/project-structure.md' + - Glossary: glossary.md - FAQ: FAQ.md diff --git a/package.json b/package.json index 3be94c19cf..527304b4f3 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,10 @@ "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", + "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", - "release": "changeset version && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}'", + "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install", "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", @@ -38,18 +37,20 @@ }, "resolutions": { "**/@roadiehq/**/@backstage/core": "*", + "**/@roadiehq/**/@backstage/plugin-catalog": "*", "**/@roadiehq/**/@backstage/catalog-model": "*" }, "version": "1.0.0", "devDependencies": { - "@changesets/cli": "^2.11.0", + "@changesets/cli": "^2.14.0", + "@octokit/openapi-types": "^2.2.0", "@spotify/eslint-config-oss": "^1.0.1", - "@spotify/prettier-config": "^8.0.0", + "@spotify/prettier-config": "^9.0.0", "command-exists": "^1.2.9", "concurrently": "^5.2.0", "fs-extra": "^9.0.0", "husky": "^4.2.3", - "lerna": "^3.20.2", + "lerna": "^4.0.0", "lint-staged": "^10.1.0", "prettier": "^2.0.5", "recursive-readdir": "^2.2.2", @@ -75,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 1b6e5cf1e0..f9166939c6 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,491 @@ # example-app +## 0.2.17 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [a5f42cf66] +- Updated dependencies [38205492a] +- Updated dependencies [e488f0502] +- Updated dependencies [e799e74d4] +- Updated dependencies [e3bc5aad7] +- Updated dependencies [a5f42cf66] +- Updated dependencies [a8953a9c9] +- Updated dependencies [f37992797] +- Updated dependencies [347137ccf] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [d6593abe6] +- Updated dependencies [bad21a085] +- Updated dependencies [e8e35fb5f] +- Updated dependencies [9615e68fb] +- Updated dependencies [e780e119c] +- Updated dependencies [437bac549] +- Updated dependencies [9f2b3a26e] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [968b588f7] +- Updated dependencies [3a58084b6] +- Updated dependencies [2499f6cde] +- Updated dependencies [5469a9761] +- Updated dependencies [60d1bc3e7] +- Updated dependencies [2c1f2a7c2] +- Updated dependencies [6266ddd11] + - @backstage/core@0.6.3 + - @backstage/plugin-scaffolder@0.6.0 + - @backstage/plugin-cost-insights@0.8.2 + - @backstage/plugin-org@0.3.8 + - @backstage/plugin-catalog@0.4.0 + - @backstage/plugin-catalog-import@0.4.2 + - @backstage/plugin-techdocs@0.5.8 + - @backstage/plugin-explore@0.2.7 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/plugin-api-docs@0.4.7 + - @backstage/catalog-model@0.7.2 + - @backstage/cli@0.6.2 + - @backstage/plugin-tech-radar@0.3.6 + - @backstage/plugin-circleci@0.2.10 + - @backstage/plugin-cloudbuild@0.2.11 + - @backstage/plugin-github-actions@0.3.4 + - @backstage/plugin-jenkins@0.3.11 + - @backstage/plugin-kafka@0.2.4 + - @backstage/plugin-kubernetes@0.3.11 + - @backstage/plugin-lighthouse@0.2.12 + - @backstage/plugin-pagerduty@0.3.1 + - @backstage/plugin-register-component@0.2.11 + - @backstage/plugin-rollbar@0.3.2 + - @backstage/plugin-search@0.3.2 + - @backstage/plugin-sentry@0.3.7 + +## 0.2.16 + +### Patch Changes + +- Updated dependencies [6c4a76c59] +- Updated dependencies [32a950409] +- Updated dependencies [f10950bd2] +- Updated dependencies [914c89b13] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [257a753ff] +- Updated dependencies [d872f662d] +- Updated dependencies [9337f509d] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [e8692df4a] +- Updated dependencies [53b69236d] +- Updated dependencies [549a859ac] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [532bc0ec0] +- Updated dependencies [688b73110] + - @backstage/plugin-scaffolder@0.5.1 + - @backstage/plugin-catalog@0.3.2 + - @backstage/core@0.6.2 + - @backstage/cli@0.6.1 + - @backstage/plugin-user-settings@0.2.6 + - @backstage/plugin-catalog-react@0.0.4 + - @backstage/plugin-api-docs@0.4.6 + - @backstage/plugin-catalog-import@0.4.1 + - @backstage/plugin-github-actions@0.3.3 + - @backstage/plugin-jenkins@0.3.10 + - @backstage/plugin-lighthouse@0.2.11 + - @backstage/plugin-org@0.3.7 + - @backstage/plugin-sentry@0.3.6 + - @backstage/plugin-pagerduty@0.3.0 + - @backstage/plugin-circleci@0.2.9 + - @backstage/plugin-cloudbuild@0.2.10 + - @backstage/plugin-explore@0.2.6 + - @backstage/plugin-kafka@0.2.3 + - @backstage/plugin-kubernetes@0.3.10 + - @backstage/plugin-register-component@0.2.10 + - @backstage/plugin-rollbar@0.3.1 + - @backstage/plugin-search@0.3.1 + - @backstage/plugin-techdocs@0.5.7 + +## 0.2.15 + +### Patch Changes + +- 07bafa248: Add configurable `scope` for oauth2 auth provider. + + Some OAuth2 providers require certain scopes to facilitate a user sign-in using the Authorization Code flow. + This change adds the optional `scope` key to auth.providers.oauth2. An example is: + + ```yaml + auth: + providers: + oauth2: + development: + clientId: + $env: DEV_OAUTH2_CLIENT_ID + clientSecret: + $env: DEV_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: DEV_OAUTH2_AUTH_URL + tokenUrl: + $env: DEV_OAUTH2_TOKEN_URL + scope: saml-login-selector openid profile email + ``` + + This tells the OAuth 2.0 AS to perform a SAML login and return OIDC information include the `profile` + and `email` claims as part of the ID Token. + +- Updated dependencies [753bb4c40] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [b33fa4cf4] +- Updated dependencies [d36660721] +- Updated dependencies [6b26c9f41] +- Updated dependencies [b3f0c3811] +- Updated dependencies [302795d10] +- Updated dependencies [9ec66c345] +- Updated dependencies [53d3e2d62] +- Updated dependencies [ca559171b] +- Updated dependencies [53348f0af] +- Updated dependencies [f5e564cd6] +- Updated dependencies [68dd79d83] +- Updated dependencies [29a138636] +- Updated dependencies [14aef4b94] +- Updated dependencies [41af18227] +- Updated dependencies [1df75733e] +- Updated dependencies [02d6803e8] +- Updated dependencies [b288a291e] +- Updated dependencies [025c0c7bf] +- Updated dependencies [e5da858d7] +- Updated dependencies [9230d07e7] +- Updated dependencies [f5f45744e] +- Updated dependencies [0fe8ff5be] +- Updated dependencies [c5ab91ce3] +- Updated dependencies [64b9efac2] +- Updated dependencies [19d354c78] +- Updated dependencies [7716d1d70] +- Updated dependencies [8f3443427] +- Updated dependencies [b51ee6ece] +- Updated dependencies [accdfeb30] +- Updated dependencies [804502a5c] + - @backstage/plugin-catalog-import@0.4.0 + - @backstage/plugin-catalog@0.3.1 + - @backstage/plugin-kubernetes@0.3.9 + - @backstage/plugin-rollbar@0.3.0 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-cost-insights@0.8.1 + - @backstage/plugin-circleci@0.2.8 + - @backstage/plugin-search@0.3.0 + - @backstage/plugin-cloudbuild@0.2.9 + - @backstage/plugin-register-component@0.2.9 + - @backstage/plugin-sentry@0.3.5 + - @backstage/plugin-jenkins@0.3.9 + - @backstage/plugin-api-docs@0.4.5 + - @backstage/plugin-lighthouse@0.2.10 + - @backstage/plugin-techdocs@0.5.6 + - @backstage/plugin-pagerduty@0.2.8 + - @backstage/plugin-org@0.3.6 + - @backstage/plugin-github-actions@0.3.2 + - @backstage/plugin-explore@0.2.5 + - @backstage/plugin-newrelic@0.2.5 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/plugin-kafka@0.2.2 + - @backstage/core@0.6.1 + - @backstage/plugin-gitops-profiles@0.2.5 + - @backstage/plugin-tech-radar@0.3.5 + +## 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 + +- Updated dependencies [19554f6d6] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [7d72f9b09] + - @backstage/cli@0.4.3 + - @backstage/plugin-github-actions@0.2.6 + - @backstage/plugin-scaffolder@0.3.5 + - @backstage/core@0.4.2 + - @backstage/test-utils@0.1.6 + - @backstage/plugin-graphiql@0.2.3 + - @backstage/plugin-catalog@0.2.8 + - @backstage/plugin-pagerduty@0.2.4 + - @backstage/plugin-techdocs@0.5.1 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [c0fac6163] +- Updated dependencies [48c305e69] +- Updated dependencies [c911061b7] +- Updated dependencies [ab805860a] +- Updated dependencies [dae4f3983] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [f3e75508d] +- Updated dependencies [3b50f833d] +- Updated dependencies [c36a01b4c] +- Updated dependencies [c5297baeb] +- Updated dependencies [ac3560b42] +- Updated dependencies [962d1ad66] + - @backstage/plugin-org@0.3.2 + - @backstage/plugin-cost-insights@0.5.2 + - @backstage/catalog-model@0.6.0 + - @backstage/plugin-techdocs@0.5.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog-import@0.3.2 + - @backstage/plugin-pagerduty@0.2.3 + - @backstage/cli@0.4.2 + - @backstage/plugin-sentry@0.3.1 + - @backstage/plugin-api-docs@0.4.1 + - @backstage/plugin-catalog@0.2.7 + - @backstage/plugin-circleci@0.2.5 + - @backstage/plugin-cloudbuild@0.2.5 + - @backstage/plugin-github-actions@0.2.5 + - @backstage/plugin-jenkins@0.3.4 + - @backstage/plugin-kubernetes@0.3.3 + - @backstage/plugin-lighthouse@0.2.6 + - @backstage/plugin-register-component@0.2.5 + - @backstage/plugin-rollbar@0.2.7 + - @backstage/plugin-scaffolder@0.3.4 + - @backstage/plugin-search@0.2.4 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [b2a07d2dc] +- Updated dependencies [075d3dc5a] +- Updated dependencies [e3071a0d4] +- Updated dependencies [d6e8099ed] +- Updated dependencies [88ef11b45] +- Updated dependencies [06dbe707b] +- Updated dependencies [2527628e1] +- Updated dependencies [011708102] +- Updated dependencies [04efbbdd2] +- Updated dependencies [90458fed6] +- Updated dependencies [6011b7d3e] +- Updated dependencies [79418ddb6] +- Updated dependencies [d2938af4c] +- Updated dependencies [61897fb2c] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [6a0d7a9fb] +- Updated dependencies [0f8877168] +- Updated dependencies [1c69d4716] +- Updated dependencies [246799c7f] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [87a33d2fe] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [2b71db211] + - @backstage/plugin-jenkins@0.3.3 + - @backstage/plugin-sentry@0.3.0 + - @backstage/plugin-cost-insights@0.5.0 + - @backstage/cli@0.4.1 + - @backstage/core@0.4.0 + - @backstage/plugin-circleci@0.2.4 + - @backstage/plugin-catalog@0.2.6 + - @backstage/plugin-pagerduty@0.2.2 + - @backstage/plugin-catalog-import@0.3.1 + - @backstage/test-utils@0.1.5 + - @backstage/plugin-search@0.2.3 + - @backstage/plugin-api-docs@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/plugin-techdocs@0.4.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-org@0.3.1 + - @backstage/plugin-cloudbuild@0.2.4 + - @backstage/plugin-explore@0.2.2 + - @backstage/plugin-gcp-projects@0.2.2 + - @backstage/plugin-github-actions@0.2.4 + - @backstage/plugin-gitops-profiles@0.2.2 + - @backstage/plugin-graphiql@0.2.2 + - @backstage/plugin-kubernetes@0.3.2 + - @backstage/plugin-lighthouse@0.2.5 + - @backstage/plugin-newrelic@0.2.2 + - @backstage/plugin-register-component@0.2.4 + - @backstage/plugin-rollbar@0.2.6 + - @backstage/plugin-scaffolder@0.3.3 + - @backstage/plugin-tech-radar@0.3.1 + - @backstage/plugin-user-settings@0.2.3 + - @backstage/plugin-welcome@0.2.3 + ## 0.2.5 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index fd6667ac85..d9b9a32c0a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,45 +1,47 @@ { "name": "example-app", - "version": "0.2.5", + "version": "0.2.17", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/cli": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-api-docs": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/plugin-catalog-import": "^0.3.0", - "@backstage/plugin-circleci": "^0.2.3", - "@backstage/plugin-cloudbuild": "^0.2.3", - "@backstage/plugin-cost-insights": "^0.4.2", - "@backstage/plugin-explore": "^0.2.1", - "@backstage/plugin-gcp-projects": "^0.2.1", - "@backstage/plugin-github-actions": "^0.2.3", - "@backstage/plugin-gitops-profiles": "^0.2.1", - "@backstage/plugin-graphiql": "^0.2.1", - "@backstage/plugin-jenkins": "^0.3.2", - "@backstage/plugin-kubernetes": "^0.3.1", - "@backstage/plugin-lighthouse": "^0.2.4", - "@backstage/plugin-newrelic": "^0.2.1", - "@backstage/plugin-register-component": "^0.2.3", - "@backstage/plugin-rollbar": "^0.2.5", - "@backstage/plugin-scaffolder": "^0.3.2", - "@backstage/plugin-sentry": "^0.2.4", - "@backstage/plugin-search": "^0.2.2", - "@backstage/plugin-tech-radar": "^0.3.0", - "@backstage/plugin-techdocs": "^0.3.1", - "@backstage/plugin-user-settings": "^0.2.2", - "@backstage/plugin-welcome": "^0.2.2", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/cli": "^0.6.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-api-docs": "^0.4.7", + "@backstage/plugin-catalog": "^0.4.0", + "@backstage/plugin-catalog-import": "^0.4.2", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/plugin-circleci": "^0.2.10", + "@backstage/plugin-cloudbuild": "^0.2.11", + "@backstage/plugin-cost-insights": "^0.8.2", + "@backstage/plugin-explore": "^0.2.7", + "@backstage/plugin-gcp-projects": "^0.2.4", + "@backstage/plugin-github-actions": "^0.3.4", + "@backstage/plugin-gitops-profiles": "^0.2.5", + "@backstage/plugin-graphiql": "^0.2.7", + "@backstage/plugin-jenkins": "^0.3.11", + "@backstage/plugin-kafka": "^0.2.4", + "@backstage/plugin-kubernetes": "^0.3.11", + "@backstage/plugin-lighthouse": "^0.2.12", + "@backstage/plugin-newrelic": "^0.2.5", + "@backstage/plugin-org": "^0.3.8", + "@backstage/plugin-pagerduty": "0.3.1", + "@backstage/plugin-register-component": "^0.2.11", + "@backstage/plugin-rollbar": "^0.3.2", + "@backstage/plugin-scaffolder": "^0.6.0", + "@backstage/plugin-sentry": "^0.3.7", + "@backstage/plugin-search": "^0.3.2", + "@backstage/plugin-tech-radar": "^0.3.6", + "@backstage/plugin-techdocs": "^0.5.8", + "@backstage/plugin-user-settings": "^0.2.6", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", - "@octokit/rest": "^18.0.0", - "@roadiehq/backstage-plugin-github-insights": "^0.2.16", - "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3", - "@roadiehq/backstage-plugin-travis-ci": "^0.2.8", + "@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.7.6", + "@roadiehq/backstage-plugin-travis-ci": "^0.2.8", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", @@ -51,9 +53,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/test-utils": "^0.1.8", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/jquery": "^3.3.34", @@ -88,5 +91,8 @@ "last 1 safari version" ] }, + "files": [ + "dist" + ], "license": "Apache-2.0" } diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 40c49416bf..cf70a7be27 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -25,7 +25,10 @@ describe('App', () => { value: [ { data: { - app: { title: 'Test' }, + app: { + title: 'Test', + support: { url: 'http://localhost:7000/support' }, + }, backend: { baseUrl: 'http://localhost:7000' }, lighthouse: { baseUrl: 'http://localhost:3003', diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 28aebeeb79..5b6f7b95fe 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -15,33 +15,51 @@ */ import { - createApp, AlertDisplay, + createApp, + FlatRoutes, OAuthRequestDialog, SignInPage, - createRouteRef, } from '@backstage/core'; -import React, { FC } from 'react'; -import Root from './components/Root'; -import * as plugins from './plugins'; -import { apis } from './apis'; +import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; +import { + CatalogEntityPage, + CatalogIndexPage, + catalogPlugin, +} from '@backstage/plugin-catalog'; +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; +import { + CostInsightsLabelDataflowInstructionsPage, + CostInsightsPage, + CostInsightsProjectGrowthInstructionsPage, +} from '@backstage/plugin-cost-insights'; +import { ExplorePage } from '@backstage/plugin-explore'; +import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; +import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { LighthousePage } from '@backstage/plugin-lighthouse'; +import { NewRelicPage } from '@backstage/plugin-newrelic'; +import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { SearchPage } from '@backstage/plugin-search'; +import { TechRadarPage } from '@backstage/plugin-tech-radar'; +import { TechdocsPage } from '@backstage/plugin-techdocs'; +import { UserSettingsPage } from '@backstage/plugin-user-settings'; +import AlarmIcon from '@material-ui/icons/Alarm'; +import React from 'react'; 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 GraphiQLRouter } from '@backstage/plugin-graphiql'; -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; -import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; -import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; -import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; -import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; -import { Route, Routes, Navigate } from 'react-router'; - +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, plugins: Object.values(plugins), + icons: { + // Custom icon example + alert: AlarmIcon, + }, components: { SignInPage: props => { return ( @@ -54,52 +72,62 @@ const app = createApp({ ); }, }, + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + bind(apiDocsPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, }); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const deprecatedAppRoutes = app.getRoutes(); -const catalogRouteRef = createRouteRef({ - path: '/catalog', - title: 'Service Catalog', -}); - -const AppRoutes = () => ( - +const routes = ( + + } /> } - /> - } - /> - } /> + path="/catalog/:namespace/:kind/:name" + element={} + > + + + } /> + } /> + } /> + } /> } + element={} /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } + path="/cost-insights/investigating-growth" + element={} /> - } /> - {...deprecatedAppRoutes} - + } + /> + } /> +
); -const App: FC<{}> = () => ( +const App = () => ( - - - + {routes} ); diff --git a/packages/app/src/components/Root/LogoFull.tsx b/packages/app/src/components/Root/LogoFull.tsx index d2b1bf1080..2fb767465b 100644 --- a/packages/app/src/components/Root/LogoFull.tsx +++ b/packages/app/src/components/Root/LogoFull.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -26,7 +26,7 @@ const useStyles = makeStyles({ fill: '#7df3e1', }, }); -const LogoFull: FC<{}> = () => { +const LogoFull = () => { const classes = useStyles(); return ( diff --git a/packages/app/src/components/Root/LogoIcon.tsx b/packages/app/src/components/Root/LogoIcon.tsx index d70be3dd32..507e47ddb9 100644 --- a/packages/app/src/components/Root/LogoIcon.tsx +++ b/packages/app/src/components/Root/LogoIcon.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -27,7 +27,7 @@ const useStyles = makeStyles({ }, }); -const LogoIcon: FC<{}> = () => { +const LogoIcon = () => { const classes = useStyles(); return ( diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 47e7d32d1a..80706fe459 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ -import React, { FC, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { useContext, PropsWithChildren } from 'react'; import { Link, makeStyles } from '@material-ui/core'; 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'; @@ -55,7 +55,7 @@ const useSidebarLogoStyles = makeStyles({ }, }); -const SidebarLogo: FC<{}> = () => { +const SidebarLogo = () => { const classes = useSidebarLogoStyles(); const { isOpen } = useContext(SidebarContext); @@ -73,7 +73,7 @@ const SidebarLogo: FC<{}> = () => { ); }; -const Root: FC<{}> = ({ children }) => ( +const Root = ({ children }: PropsWithChildren<{}>) => ( @@ -83,6 +83,7 @@ const Root: FC<{}> = ({ children }) => ( + {/* End global nav */} @@ -102,8 +103,4 @@ const Root: FC<{}> = ({ children }) => ( ); -Root.propTypes = { - children: PropTypes.node, -}; - export default Root; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 068019c622..fb56b40e68 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,17 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiEntity, Entity } from '@backstage/catalog-model'; + +import { + ApiEntity, + DomainEntity, + Entity, + GroupEntity, + SystemEntity, + UserEntity, +} from '@backstage/catalog-model'; import { EmptyState } from '@backstage/core'; import { ApiDefinitionCard, - Router as ApiDocsRouter, + ConsumedApisCard, + ConsumingComponentsCard, + EntityHasApisCard, + ProvidedApisCard, + ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { AboutCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, + EntityLinksCard, EntityPageLayout, - useEntity, } from '@backstage/plugin-catalog'; +import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter, @@ -42,12 +58,27 @@ 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, isPluginApplicableToEntity as isLighthouseAvailable, LastLighthouseAuditCard, } from '@backstage/plugin-lighthouse'; +import { + 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'; @@ -84,12 +115,12 @@ export const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; case isCircleCIAvailable(entity): return ; - case isGitHubActionsAvailable(entity): - return ; case isCloudbuildAvailable(entity): return ; case isTravisCIAvailable(entity): return ; + case isGitHubActionsAvailable(entity): + return ; default: return ( { case isJenkinsAvailable(entity): content = ; break; + case isTravisCIAvailable(entity): + content = ; + break; case isGitHubActionsAvailable(entity): content = ( ); break; - case isTravisCIAvailable(entity): - content = ; - break; default: content = null; } @@ -137,11 +168,30 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { ); }; +export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => { + switch (true) { + case isRollbarAvailable(entity): + return ; + default: + return ; + } +}; + const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( + {isPagerDutyAvailable(entity) && ( + + + + + + )} + + + {isGitHubAvailable(entity) && ( <> @@ -164,6 +214,20 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( )} + + + + +); + +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + ); @@ -180,14 +244,14 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( element={} /> } + path="/errors/*" + title="Errors" + element={} /> } + element={} /> ( title="Code Insights" element={} /> + } + /> ); @@ -230,9 +299,9 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( element={} /> } + path="/errors/*" + title="Errors" + element={} /> ( + + + + + + + + ); @@ -314,6 +391,96 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => ( ); +const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( + + + + + + + + +); + +const UserEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( + + + + + + + + + + + +); + +const GroupEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( + + + + + + + + + + + +); + +const SystemEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( + + + + + + + + +); + +const DomainEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + export const EntityPage = () => { const { entity } = useEntity(); @@ -322,6 +489,14 @@ export const EntityPage = () => { return ; case 'api': return ; + case 'group': + return ; + case 'user': + return ; + case 'system': + return ; + case 'domain': + return ; default: return ; } diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 01828b1405..6f45f9ba32 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -22,6 +22,7 @@ import { samlAuthApiRef, microsoftAuthApiRef, oneloginAuthApiRef, + oauth2ApiRef, oidcAuthApiRef, } from '@backstage/core'; @@ -32,6 +33,12 @@ export const providers = [ message: 'Sign In using OpenId Connect', apiRef: oidcAuthApiRef, }, + { + id: 'oauth2-auth-provider', + title: 'OAuth 2.0', + message: 'Sign In using OAuth 2.0', + apiRef: oauth2ApiRef, + }, { id: 'google-auth-provider', title: 'Google', diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index a38159a258..71fd40dd7e 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -// eslint-disable-next-line monorepo/no-internal-import import '@backstage/cli/asset-types'; import React from 'react'; import ReactDOM from 'react-dom'; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a02365d17d..862d821ed6 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin as WelcomePlugin } from '@backstage/plugin-welcome'; export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { catalogPlugin } from '@backstage/plugin-catalog'; +export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; -export { plugin as Explore } from '@backstage/plugin-explore'; +export { explorePlugin } from '@backstage/plugin-explore'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; export { plugin as Sentry } from '@backstage/plugin-sentry'; @@ -39,5 +38,8 @@ export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights'; export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; export { plugin as UserSettings } from '@backstage/plugin-user-settings'; +export { plugin as PagerDuty } from '@backstage/plugin-pagerduty'; export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite'; export { plugin as Search } from '@backstage/plugin-search'; +export { plugin as Org } from '@backstage/plugin-org'; +export { plugin as Kafka } from '@backstage/plugin-kafka'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 0c3741ec84..bb913c8fdb 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,293 @@ # @backstage/backend-common +## 0.5.4 + +### Patch Changes + +- 16fb1d03a: pass registered logger to requestLoggingHandler +- 491f3a0ec: Implement `UrlReader.search` for the other providers (Azure, Bitbucket, GitLab) as well. + + The `UrlReader` subclasses now are implemented in terms of the respective `Integration` class. + +- 434b4e81a: Support globs in `FileReaderProcessor`. +- fb28da212: Switched to using `'x-access-token'` for authenticating Git over HTTPS towards GitHub. +- Updated dependencies [491f3a0ec] + - @backstage/integration@0.5.0 + +## 0.5.3 + +### Patch Changes + +- ffffea8e6: Minor updates to reflect the changes in `@backstage/integration` that made the fields `apiBaseUrl` and `apiUrl` mandatory. +- 82b2c11b6: Set explicit content-type in error handler responses. +- 965e200c6: Slight refactoring in support of a future search implementation in `UrlReader`. Mostly moving code around. +- 5a5163519: Implement `UrlReader.search` which implements glob matching. +- Updated dependencies [ffffea8e6] + - @backstage/integration@0.4.0 + +## 0.5.2 + +### Patch Changes + +- 2430ee7c2: Updated the `rootLogger` in `@backstage/backend-common` to support custom logging options. This is useful when you want to make some changes without re-implementing the entire logger and calling `setRootLogger` or `logger.configure`. For example you can add additional `defaultMeta` tags to each log entry. The following changes are included: + + - Added `createRootLogger` which accepts winston `LoggerOptions`. These options allow overriding the default keys. + + Example Usage: + + ```ts + // Create the logger + const logger = createRootLogger({ + defaultMeta: { appName: 'backstage', appEnv: 'prod' }, + }); + + // Add a custom logger transport + logger.add(new MyCustomTransport()); + + const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), // already set to new logger instance + }); + ``` + +- Updated dependencies [c4abcdb60] +- Updated dependencies [062df71db] +- Updated dependencies [064c513e1] +- Updated dependencies [e9aab60c7] +- Updated dependencies [3149bfe63] +- Updated dependencies [2e62aea6f] + - @backstage/integration@0.3.2 + - @backstage/config-loader@0.5.1 + +## 0.5.1 + +### 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. + +- 664dd08c9: URL Reader's readTree: Fix bug with github.com 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 [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/integration@0.3.1 + - @backstage/config-loader@0.5.0 + +## 0.5.0 + +### Minor Changes + +- 5345a1f98: Remove fallback option from `UrlReaders.create` and `UrlReaders.default`, as well as the default fallback reader. + + To be able to read data from endpoints outside of the configured integrations, you now 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' + ``` + + Apart from adding the above configuration, most projects should not need to take any action to migrate existing code. If you do happen to have your own fallback reader configured, this needs to be replaced with a reader factory that selects a specific set of URLs to work with. If you where wrapping the existing fallback reader, the new one that handles the allow list is created using `FetchUrlReader.factory`. + +- 09a370426: Remove support for HTTPS certificate generation parameters. Use `backend.https = true` instead. + +### 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`. + +- 294a70cab: 1. URL Reader's `readTree` method now returns an `etag` in the response along with the blob. The etag is an identifier of the blob and will only change if the blob is modified on the target. Usually it is set to the latest commit SHA on the target. + + `readTree` also takes an optional `etag` in its options and throws a `NotModifiedError` if the etag matches with the etag of the resource. + + So, the `etag` can be used in building a cache when working with URL Reader. + + An example - + + ```ts + const response = await reader.readTree( + 'https://github.com/backstage/backstage', + ); + + const etag = response.etag; + + // Will throw a new NotModifiedError (exported from @backstage/backstage-common) + await reader.readTree('https://github.com/backstage/backstage', { + etag, + }); + ``` + + 2. URL Reader's readTree method can now detect the default branch. So, `url:https://github.com/org/repo/tree/master` can be replaced with `url:https://github.com/org/repo` in places like `backstage.io/techdocs-ref`. + +- 0ea032763: URL Reader: Use API response headers for archive filename in readTree. Fixes bug for users with hosted Bitbucket. +- Updated dependencies [0b135e7e0] +- Updated dependencies [fa8ba330a] +- Updated dependencies [ed6baab66] + - @backstage/integration@0.3.0 + +## 0.4.3 + +### Patch Changes + +- Updated dependencies [466354aaa] + - @backstage/integration@0.2.0 + +## 0.4.2 + +### Patch Changes + +- 5ecd50f8a: 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. +- 00042e73c: Moving the Git actions to isomorphic-git instead of the node binding version of nodegit +- 0829ff126: Tweaked development log formatter to include extra fields at the end of each log line +- 036a84373: Provide support for on-prem azure devops +- Updated dependencies [ad5c56fd9] +- Updated dependencies [036a84373] + - @backstage/config-loader@0.4.1 + - @backstage/integration@0.1.5 + +## 0.4.1 + +### Patch Changes + +- 1d1c2860f: Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl +- 4eafdec4a: Introduce readTree method for GitLab URL Reader +- Updated dependencies [1d1c2860f] +- Updated dependencies [4eafdec4a] +- Updated dependencies [178e09323] + - @backstage/integration@0.1.4 + +## 0.4.0 + +### Minor Changes + +- 12bbd748c: Removes the Prometheus integration from `backend-common`. + + Rational behind this change is to keep the metrics integration of Backstage + generic. Instead of directly relying on Prometheus, Backstage will expose + metrics in a generic way. Integrators can then export the metrics in their + desired format. For example using Prometheus. + + To keep the existing behavior, you need to integrate Prometheus in your + backend: + + First, add a dependency on `express-prom-bundle` and `prom-client` to your backend. + + ```diff + // packages/backend/package.json + "dependencies": { + + "express-prom-bundle": "^6.1.0", + + "prom-client": "^12.0.0", + ``` + + Then, add a handler for metrics and a simple instrumentation for the endpoints. + + ```typescript + // packages/backend/src/metrics.ts + import { useHotCleanup } from '@backstage/backend-common'; + import { RequestHandler } from 'express'; + import promBundle from 'express-prom-bundle'; + import prom from 'prom-client'; + import * as url from 'url'; + + const rootRegEx = new RegExp('^/([^/]*)/.*'); + const apiRegEx = new RegExp('^/api/([^/]*)/.*'); + + export function normalizePath(req: any): string { + const path = url.parse(req.originalUrl || req.url).pathname || '/'; + + // Capture /api/ and the plugin name + if (apiRegEx.test(path)) { + return path.replace(apiRegEx, '/api/$1'); + } + + // Only the first path segment at root level + return path.replace(rootRegEx, '/$1'); + } + + /** + * Adds a /metrics endpoint, register default runtime metrics and instrument the router. + */ + export function metricsHandler(): RequestHandler { + // We can only initialize the metrics once and have to clean them up between hot reloads + useHotCleanup(module, () => prom.register.clear()); + + return promBundle({ + includeMethod: true, + includePath: true, + // Using includePath alone is problematic, as it will include path labels with high + // cardinality (e.g. path params). Instead we would have to template them. However, this + // is difficult, as every backend plugin might use different routes. Instead we only take + // the first directory of the path, to have at least an idea how each plugin performs: + normalizePath, + promClient: { collectDefaultMetrics: {} }, + }); + } + ``` + + Last, extend your router configuration with the `metricsHandler`: + + ```diff + +import { metricsHandler } from './metrics'; + + ... + + const service = createServiceBuilder(module) + .loadConfig(config) + .addRouter('', await healthcheck(healthcheckEnv)) + + .addRouter('', metricsHandler()) + .addRouter('/api', apiRouter); + ``` + + Your Prometheus metrics will be available at the `/metrics` endpoint. + +### Patch Changes + +- 38e24db00: Move the core url and auth logic to integration for the four major providers +- Updated dependencies [38e24db00] +- Updated dependencies [b8ecf6f48] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/integration@0.1.3 + - @backstage/config@0.1.2 + ## 0.3.3 ### Patch Changes diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 3e21235c63..74c199e737 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -32,17 +32,17 @@ export interface Config { port?: string | number; }; - /** HTTPS configuration for the backend. If omitted the backend will serve HTTP */ - https?: { - /** Certificate configuration or parameters for generating a self-signed certificate */ - certificate?: - | { - /** Algorithm to use to generate a self-signed certificate */ - algorithm: string; - keySize?: number; - days?: number; - } - | { + /** + * HTTPS configuration for the backend. If omitted the backend will serve HTTP. + * + * Setting this to `true` will cause self-signed certificates to be generated, which + * can be useful for local development or other non-production scenarios. + */ + https?: + | true + | { + /** Certificate configuration */ + certificate?: { /** PEM encoded certificate. Use $file to load in a file */ cert: string; /** @@ -51,7 +51,7 @@ export interface Config { */ key: string; }; - }; + }; /** Database connection configuration, select database type using the `client` field */ database: @@ -79,6 +79,26 @@ export interface Config { optionsSuccessStatus?: number; }; + /** + * Configuration related to URL reading, used for example for reading catalog info + * files, scaffolder templates, and techdocs content. + */ + reading?: { + /** + * A list of targets to allow outgoing requests to. Users will be able to make + * requests on behalf of the backend to the targets that are allowed by this list. + */ + allow?: Array<{ + /** + * A host to allow outgoing requests to, being either a full host or + * a subdomain wildcard pattern with a leading `*`. For example `example.com` + * and `*.example.com` are valid values, `prod.*.example.com` is not. + * The host may also contain a port, for example `example.com:8080`. + */ + host: string; + }>; + }; + /** * Content Security Policy options. * @@ -89,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 04e189abca..3cd817427f 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.3.3", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,9 +30,10 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", - "@backstage/config-loader": "^0.4.0", - "@backstage/integration": "^0.1.2", + "@backstage/config": "^0.1.2", + "@backstage/config-loader": "^0.5.1", + "@backstage/integration": "^0.5.0", + "@octokit/rest": "^18.0.12", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -41,17 +42,17 @@ "cors": "^2.8.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.0", + "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", - "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", "tar": "^6.0.5", @@ -67,9 +68,9 @@ } }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/test-utils": "^0.1.4", - "@types/archiver": "^3.1.1", + "@backstage/cli": "^0.6.1", + "@backstage/test-utils": "^0.1.7", + "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", "@types/fs-extra": "^9.0.3", @@ -83,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/database/SingleConnection.test.ts b/packages/backend-common/src/database/SingleConnection.test.ts index 6278e4ecad..3466b0f79d 100644 --- a/packages/backend-common/src/database/SingleConnection.test.ts +++ b/packages/backend-common/src/database/SingleConnection.test.ts @@ -21,14 +21,6 @@ import { SingleConnectionDatabaseManager } from './SingleConnection'; jest.mock('./connection'); describe('SingleConnectionDatabaseManager', () => { - const createConfig = (data: any) => - ConfigReader.fromConfigs([ - { - context: '', - data, - }, - ]); - const defaultConfigOptions = { backend: { database: { @@ -42,7 +34,7 @@ describe('SingleConnectionDatabaseManager', () => { }, }, }; - const defaultConfig = () => createConfig(defaultConfigOptions); + const defaultConfig = () => new ConfigReader(defaultConfigOptions); // This is similar to the ts-jest `mocked` helper. const mocked = (f: Function) => f as jest.Mock; diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index de0b0e40dc..9810ff9bef 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -18,19 +18,11 @@ import { ConfigReader } from '@backstage/config'; import { createDatabaseClient } from './connection'; describe('database connection', () => { - const createConfig = (data: any) => - ConfigReader.fromConfigs([ - { - context: '', - data, - }, - ]); - describe('createDatabaseClient', () => { it('returns a postgres connection', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', connection: { host: 'acme', @@ -46,7 +38,7 @@ describe('database connection', () => { it('returns an sqlite connection', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'sqlite3', connection: ':memory:', }), @@ -57,7 +49,7 @@ describe('database connection', () => { it('tries to create a mysql connection as a passthrough', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'mysql', connection: { host: '127.0.0.1', @@ -73,7 +65,7 @@ describe('database connection', () => { it('accepts overrides', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', connection: { host: 'acme', @@ -94,7 +86,7 @@ describe('database connection', () => { it('throws an error without a client', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ connection: '', }), ), @@ -104,7 +96,7 @@ describe('database connection', () => { it('throws an error without a connection', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', }), ), diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts index 8d139e4481..59c135f309 100644 --- a/packages/backend-common/src/database/postgres.test.ts +++ b/packages/backend-common/src/database/postgres.test.ts @@ -34,15 +34,7 @@ describe('postgres', () => { 'postgresql://foo:bar@acme:5432/foodb'; const createConfig = (connection: any): Config => - ConfigReader.fromConfigs([ - { - context: '', - data: { - client: 'pg', - connection, - }, - }, - ]); + new ConfigReader({ client: 'pg', connection }); describe('buildPgDatabaseConfig', () => { it('builds a postgres config', () => { diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts index a3ab331c2d..a6b8e5d84d 100644 --- a/packages/backend-common/src/database/sqlite3.test.ts +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -22,15 +22,7 @@ import { describe('sqlite3', () => { const createConfig = (connection: any) => - ConfigReader.fromConfigs([ - { - context: '', - data: { - client: 'sqlite3', - connection, - }, - }, - ]); + new ConfigReader({ client: 'sqlite3', connection }); describe('buildSqliteDatabaseConfig', () => { it('buidls a string connection', () => { 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/index.ts b/packages/backend-common/src/index.ts index e968edef69..3f04f32b34 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -24,3 +24,4 @@ export * from './reading'; export * from './service'; export * from './paths'; export * from './hot'; +export * from './scm'; diff --git a/packages/backend-common/src/logging/formats.ts b/packages/backend-common/src/logging/formats.ts index 5f00448744..d24b9509dd 100644 --- a/packages/backend-common/src/logging/formats.ts +++ b/packages/backend-common/src/logging/formats.ts @@ -13,23 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import * as winston from 'winston'; import { TransformableInfo } from 'logform'; const coloredTemplate = (info: TransformableInfo) => { - const { timestamp, level, message, plugin, service } = info; + const { timestamp, level, message, plugin, service, ...fields } = info; const colorizer = winston.format.colorize(); const prefix = plugin || service; const timestampColor = colorizer.colorize('timestamp', timestamp); const prefixColor = colorizer.colorize('prefix', prefix); - return `${timestampColor} ${prefixColor} ${level} ${message}`; + const extraFields = Object.entries(fields) + .map(([key, value]) => `${colorizer.colorize('field', `${key}`)}=${value}`) + .join(' '); + + return `${timestampColor} ${prefixColor} ${level} ${message} ${extraFields}`; }; export const coloredFormat = winston.format.combine( winston.format.timestamp(), winston.format.colorize({ - colors: { timestamp: 'dim', prefix: 'blue' }, + colors: { timestamp: 'dim', prefix: 'blue', field: 'cyan', debug: 'grey' }, }), winston.format.printf(coloredTemplate), ); 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..78baaf5090 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); @@ -130,4 +134,20 @@ describe('errorHandler', () => { expect(mockLogger.error).not.toHaveBeenCalled(); }); + + it('log 400 errors when logClientErrors is true', async () => { + const app = express(); + + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + + app.use('/NotFound', () => { + throw new errors.NotFoundError(); + }); + app.use(errorHandler({ logger: mockLogger as any, logClientErrors: true })); + + await request(app).get('/NotFound'); + + expect(mockLogger.error).toHaveBeenCalled(); + }); }); diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 7365ce8b93..4b609a649f 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -28,11 +28,18 @@ export type ErrorHandlerOptions = { showStackTraces?: boolean; /** - * Logger instance to log any 5xx errors. + * Logger instance to log errors. * * If not specified, the root logger will be used. */ logger?: Logger; + + /** + * Whether any error < 4XX should be logged or not. + * + * If not specified, by default log any 5xx errors. + */ + logClientErrors?: boolean; }; /** @@ -59,14 +66,13 @@ export function errorHandler( type: 'errorHandler', }); - /* eslint-disable @typescript-eslint/no-unused-vars */ 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); @@ -76,11 +82,13 @@ export function errorHandler( const status = getStatusCode(error); const message = showStackTraces ? error.stack : error.message; - if (logger && status >= 500) { + if (options.logClientErrors || status >= 500) { logger.error(error); } - response.status(status).send(message); + res.status(status); + res.setHeader('content-type', 'text/plain'); + res.send(message); }; } @@ -101,6 +109,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 ab97d1b073..b52097baf5 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -14,14 +14,21 @@ * limitations under the License. */ -import fs from 'fs'; -import path from 'path'; +import { ConfigReader } from '@backstage/config'; +import { + AzureIntegration, + readAzureIntegrationConfig, +} from '@backstage/integration'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { ConfigReader } from '@backstage/config'; +import * as os from 'os'; +import path from 'path'; +import { NotModifiedError } from '../errors'; import { getVoidLogger } from '../logging'; -import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader'; -import { msw } from '@backstage/test-utils'; +import { AzureUrlReader } from './AzureUrlReader'; import { ReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); @@ -30,7 +37,19 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('AzureUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -111,13 +130,13 @@ describe('AzureUrlReader', () => { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', }, { url: 'com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', }, { url: '', @@ -139,7 +158,18 @@ describe('AzureUrlReader', () => { describe('readTree', () => { const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), + ); + + const processor = new AzureUrlReader( + new AzureIntegration( + readAzureIntegrationConfig( + new ConfigReader({ + host: 'dev.azure.com', + }), + ), + ), + { treeResponseFactory }, ); beforeEach(() => { @@ -153,46 +183,163 @@ 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'); }); }); - describe('getDownloadUrl', () => { - it('do not add scopePath if no path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository', - ); + describe('search', () => { + const repoBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), + ); - expect(result.searchParams.get('scopePath')).toBeNull(); + const processor = new AzureUrlReader( + new AzureIntegration( + readAzureIntegrationConfig( + new ConfigReader({ + host: 'dev.azure.com', + }), + ), + ), + { treeResponseFactory }, + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + 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/org-name/project-name/_apis/git/repositories/repo-name/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + count: 2, + value: [ + { + commitId: '123abc2', + comment: 'second commit', + }, + { + commitId: '123abc1', + comment: 'first commit', + }, + ], + }), + ), + ), + ); }); - it('add scopePath if a path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', + it('works for the naive case', async () => { + const result = await processor.search( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2F**%2Findex.*&version=GBmaster', ); - expect(result.searchParams.get('scopePath')).toEqual('docs'); + expect(result.etag).toBe('123abc2'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2Fdocs%2Findex.md&version=GBmaster', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + processor.search( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=**/index.*&version=GBmaster', + { etag: '123abc2' }, + ), + ).rejects.toThrow(NotModifiedError); }); }); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ad990d1d5d..21b988e5b0 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -15,72 +15,53 @@ */ import { - AzureIntegrationConfig, - readAzureIntegrationConfigs, + AzureIntegration, + getAzureCommitsUrl, + getAzureDownloadUrl, + getAzureFileFetchUrl, + getAzureRequestOptions, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; -import parseGitUri from 'git-url-parse'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchOptions, + SearchResponse, UrlReader, } from './types'; -import { ReadTreeResponseFactory } from './tree'; - -export function getDownloadUrl(url: string): URL { - const { - name: repoName, - owner: project, - organization, - protocol, - resource, - filepath, - } = parseGitUri(url); - - // scopePath will limit the downloaded content - // /docs will only download the docs folder and everything below it - // /docs/index.md will only download index.md but put it in the root of the archive - const scopePath = filepath - ? `&scopePath=${encodeURIComponent(filepath)}` - : ''; - - return new URL( - `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`, - ); -} export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readAzureIntegrationConfigs( - config.getOptionalConfigArray('integrations.azure') ?? [], - ); - return configs.map(options => { - const reader = new AzureUrlReader(options, { treeResponseFactory }); - const predicate = (url: URL) => url.host === options.host; + const integrations = ScmIntegrations.fromConfig(config); + return integrations.azure.list().map(integration => { + const reader = new AzureUrlReader(integration, { treeResponseFactory }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - private readonly options: AzureIntegrationConfig, + private readonly integration: AzureIntegration, private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, - ) { - if (options.host !== 'dev.azure.com') { - throw Error( - `Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`, - ); - } - } + ) {} async read(url: string): Promise { - const builtUrl = this.buildRawUrl(url); + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch( + builtUrl, + getAzureRequestOptions(this.integration.config), + ); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -101,100 +82,80 @@ export class AzureUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const response = await fetch( - getDownloadUrl(url).toString(), - this.getRequestOptions({ 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.integration.config), ); - 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.integration.config, { + 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, }); } - // Converts - // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents - // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); - const [ - empty, - userOrOrg, - project, - srcKeyword, - repoName, - ] = url.pathname.split('/'); + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = new URL(url); + treeUrl.searchParams.delete('path'); + treeUrl.pathname = treeUrl.pathname.replace(/\/+$/, ''); - const path = url.searchParams.get('path') || ''; - const ref = url.searchParams.get('version')?.substr(2); + const tree = await this.readTree(treeUrl.toString(), { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); - if ( - url.hostname !== 'dev.azure.com' || - empty !== '' || - userOrOrg === '' || - project === '' || - srcKeyword !== '_git' || - repoName === '' || - path === '' || - ref === '' - ) { - throw new Error('Wrong Azure Devops URL or Invalid file path'); - } - - // transform to api - url.pathname = [ - empty, - userOrOrg, - project, - '_apis', - 'git', - 'repositories', - repoName, - 'items', - ].join('/'); - - const queryParams = [`path=${path}`]; - - if (ref) { - queryParams.push(`version=${ref}`); - } - - url.search = queryParams.join('&'); - - url.protocol = 'https'; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private getRequestOptions(additionalHeaders?: { - [key: string]: string; - }): RequestInit { - const headers: HeadersInit = additionalHeaders ?? {}; - - if (this.options.token) { - headers.Authorization = `Basic ${Buffer.from( - `:${this.options.token}`, - 'utf8', - ).toString('base64')}`; - } - - return { headers }; + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; } toString() { - const { host, token } = this.options; + const { host, token } = this.integration.config; 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 01744db28a..df9febb352 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -14,105 +14,404 @@ * limitations under the License. */ -import { BitbucketIntegrationConfig } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; import { - BitbucketUrlReader, - getApiRequestOptions, - getApiUrl, -} from './BitbucketUrlReader'; + BitbucketIntegration, + readBitbucketIntegrationConfig, +} from '@backstage/integration'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import os from 'os'; +import path from 'path'; +import { NotModifiedError } from '../errors'; +import { BitbucketUrlReader } from './BitbucketUrlReader'; +import { ReadTreeResponseFactory } from './tree'; -describe('BitbucketUrlReader', () => { - describe('getApiRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('Bearer A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); +const treeResponseFactory = ReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); - it('insert basic auth when needed', () => { - const withUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - username: 'some-user', - appPassword: 'my-secret', - }; - const withoutUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withUsernameAndPassword).headers as any) - .Authorization, - ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); - expect( - (getApiRequestOptions(withoutUsernameAndPassword).headers as any) - .Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - it('happy path for Bitbucket Cloud', () => { - const config: BitbucketIntegrationConfig = { +const bitbucketProcessor = new BitbucketUrlReader( + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0', - }; - expect( - getApiUrl( - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', - ), - ); - }); - it('happy path for Bitbucket Server', () => { - const config: BitbucketIntegrationConfig = { + }), + ), + ), + { treeResponseFactory }, +); + +const hostedBitbucketProcessor = new BitbucketUrlReader( + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', - }; - expect( - getApiUrl( - 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml', - ), - ); + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }), + ), + ), + { treeResponseFactory }, +); + +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + +describe('BitbucketUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), }); }); + afterEach(() => { + mockFs.restore(); + }); + + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new BitbucketUrlReader({ - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }); await expect( - processor.read('https://not.bitbucket.com/apa'), + bitbucketProcessor.read('https://not.bitbucket.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path', ); }); }); + + describe('readTree', () => { + const repoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-repo-with-commit-hash.zip', + ), + ); + + 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), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + 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' }], + }), + ), + ), + ); + }); + + 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); + const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[1].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + 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 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); + 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 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); + const indexMarkdownFile = await files[0].content(); + + 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( + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.mycompany.net', + }), + ), + ), + { treeResponseFactory }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); + }); + + describe('search hosted', () => { + const repoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-repo-with-commit-hash.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), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await bitbucketProcessor.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.org/backstage/mock/src/master/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + bitbucketProcessor.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); + + describe('search private', () => { + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); + + beforeEach(() => { + 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.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' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await hostedBitbucketProcessor.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + hostedBitbucketProcessor.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 9694c1d987..3954e7ee4a 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -15,110 +15,71 @@ */ import { - BitbucketIntegrationConfig, - readBitbucketIntegrationConfigs, + BitbucketIntegration, + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; - -export function getApiRequestOptions( - provider: BitbucketIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `Bearer ${provider.token}`; - } else if (provider.username && provider.appPassword) { - headers.Authorization = `Basic ${Buffer.from( - `${provider.username}:${provider.appPassword}`, - 'utf8', - ).toString('base64')}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml -// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml -export function getApiUrl( - target: string, - provider: BitbucketIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - if ( - !owner || - !name || - (filepathtype !== 'browse' && - filepathtype !== 'raw' && - filepathtype !== 'src') - ) { - throw new Error('Invalid Bitbucket URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - - if (provider.host === 'bitbucket.org') { - if (!ref) { - throw new Error('Invalid Bitbucket URL or file path'); - } - return new URL( - `${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`, - ); - } - return new URL( - `${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} +import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; +import { Readable } from 'stream'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + SearchOptions, + SearchResponse, + UrlReader, +} from './types'; /** * A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as * the one exposed by Bitbucket Cloud itself. */ export class BitbucketUrlReader implements UrlReader { - private readonly config: BitbucketIntegrationConfig; - - static factory: ReaderFactory = ({ config }) => { - const configs = readBitbucketIntegrationConfigs( - config.getOptionalConfigArray('integrations.bitbucket') ?? [], - ); - return configs.map(provider => { - const reader = new BitbucketUrlReader(provider); - const predicate = (url: URL) => url.host === provider.host; + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const integrations = ScmIntegrations.fromConfig(config); + return integrations.bitbucket.list().map(integration => { + const reader = new BitbucketUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; - constructor(config: BitbucketIntegrationConfig) { - const { host, apiBaseUrl, token, username, appPassword } = config; + constructor( + private readonly integration: BitbucketIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { + const { + host, + apiBaseUrl, + token, + username, + appPassword, + } = integration.config; if (!apiBaseUrl) { throw new Error( `Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`, ); - } - - if (!token && username && !appPassword) { + } else if (!token && username && !appPassword) { throw new Error( `Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`, ); } - - this.config = config; } async read(url: string): Promise { - const bitbucketUrl = getApiUrl(url, this.config); - - const options = getApiRequestOptions(this.config); + const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config); + const options = getBitbucketRequestOptions(this.integration.config); let response: Response; try { @@ -138,16 +99,125 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { - throw new Error('BitbucketUrlReader does not implement readTree'); + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { filepath } = parseGitUrl(url); + + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } + + const downloadUrl = await getBitbucketDownloadUrl( + url, + this.integration.config, + ); + const archiveBitbucketResponse = await fetch( + downloadUrl, + getBitbucketRequestOptions(this.integration.config), + ); + 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); + } + + return await this.deps.treeResponseFactory.fromZipArchive({ + stream: (archiveBitbucketResponse.body as unknown) as Readable, + subpath: filepath, + etag: lastCommitShortHash, + filter: options?.filter, + }); + } + + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; } toString() { - const { host, token, username, appPassword } = this.config; + const { host, token, username, appPassword } = this.integration.config; let authed = Boolean(token); if (!authed) { authed = Boolean(username && appPassword); } return `bitbucket{host=${host},authed=${authed}}`; } + + 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.integration.config); + } + + 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.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}` + : `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; + + const commitsResponse = await fetch( + commitsApiUrl, + getBitbucketRequestOptions(this.integration.config), + ); + if (!commitsResponse.ok) { + const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; + if (commitsResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commits = await commitsResponse.json(); + 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 abe8b4f640..94e4d0fd31 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,17 +15,24 @@ */ import { ConfigReader } from '@backstage/config'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +import { + GithubCredentialsProvider, + GitHubIntegration, + readGitHubIntegrationConfig, +} 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 os from 'os'; import path from 'path'; +import { NotFoundError, NotModifiedError } from '../errors'; import { - getApiRequestOptions, - getApiUrl, - getRawRequestOptions, - getRawUrl, + GhBlobResponse, + GhBranchResponse, + GhRepoResponse, + GhTreeResponse, GithubUrlReader, } from './GithubUrlReader'; import { ReadTreeResponseFactory } from './tree'; @@ -34,196 +41,222 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); -describe('GithubUrlReader', () => { - describe('getApiRequestOptions', () => { - it('sets the correct API version', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect((getApiRequestOptions(config).headers as any).Accept).toEqual( - 'application/vnd.github.v3.raw', - ); - }); +const mockCredentialsProvider = ({ + getCredentials: jest.fn().mockResolvedValue({ headers: {} }), +} as unknown) as GithubCredentialsProvider; - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); - - describe('getRawRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - }; - expect( - (getRawRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getRawRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { +const githubProcessor = new GithubUrlReader( + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ host: 'github.com', apiBaseUrl: 'https://api.github.com', - }; - expect( - getApiUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - }); + }), + ), + ), + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - apiBaseUrl: 'https://ghe.mycompany.net/api/v3', - }; - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); +const gheProcessor = new GithubUrlReader( + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ + host: 'ghe.github.com', + apiBaseUrl: 'https://ghe.github.com/api/v3', + }), + ), + ), + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + +describe('GithubUrlReader', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), }); }); - describe('getRawUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); + afterEach(() => { + mockFs.restore(); + }); - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { - host: 'github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }; - expect( - getRawUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', - ), - ); - }); - - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - rawBaseUrl: 'https://ghe.mycompany.net/raw', - }; - expect( - getRawUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'), - ); - }); + 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', ); }); }); + /* + * read + */ + + 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://ghe.github.com/api/v3/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 gheProcessor.read( + 'https://github.com/backstage/mock/tree/blob/main', + ); + }); + }); + + /* + * readTree + */ + describe('readTree', () => { - const worker = setupServer(); - - msw.setupDefaultHandlers(worker); - 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(); @@ -235,75 +268,509 @@ 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: 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('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: tmpDir }); + + 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( + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ + host: 'ghe.mycompany.net', + }), + ), + ), + { + treeResponseFactory, + credentialsProvider: mockCredentialsProvider, + }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); + }); + + /* + * search + */ + + describe('search', () => { + 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 de798a7067..ac5a93bd5f 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -15,106 +15,32 @@ */ import { - GitHubIntegrationConfig, - readGitHubIntegrationConfigs, + getGitHubFileFetchUrl, + GithubCredentialsProvider, + GitHubIntegration, + ScmIntegrations, } 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 function getApiRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -export function getRawRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/path/to/c.yaml -// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname -export function getApiUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/c.yaml -// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml -export function getRawUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} +export 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 @@ -122,40 +48,47 @@ export function getRawUrl( */ export class GithubUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readGitHubIntegrationConfigs( - config.getOptionalConfigArray('integrations.github') ?? [], - ); - return configs.map(provider => { - const reader = new GithubUrlReader(provider, { treeResponseFactory }); - const predicate = (url: URL) => url.host === provider.host; + const integrations = ScmIntegrations.fromConfig(config); + return integrations.github.list().map(integration => { + const credentialsProvider = GithubCredentialsProvider.create( + integration.config, + ); + const reader = new GithubUrlReader(integration, { + treeResponseFactory, + credentialsProvider, + }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - private readonly config: GitHubIntegrationConfig, - private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + private readonly integration: GitHubIntegration, + private readonly deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }, ) { - if (!config.apiBaseUrl && !config.rawBaseUrl) { + if (!integration.config.apiBaseUrl && !integration.config.rawBaseUrl) { throw new Error( - `GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`, + `GitHub integration '${integration.title}' must configure an explicit apiBaseUrl or rawBaseUrl`, ); } } async read(url: string): Promise { - const useApi = - this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl); - const ghUrl = useApi - ? getApiUrl(url, this.config) - : getRawUrl(url, this.config); - const options = useApi - ? getApiRequestOptions(this.config) - : getRawRequestOptions(this.config); - + const ghUrl = getGitHubFileFetchUrl(url, this.integration.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}`); } @@ -175,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(), - getRawRequestOptions(this.config), + 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.integration.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.integration.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 = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; + const message = `Request failed for ${urlAsString}, ${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, - }); + return response; } - toString() { - const { host, token } = this.config; - return `github{host=${host},authed=${Boolean(token)}}`; + 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 3a76c0631b..636540caa8 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -14,13 +14,22 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { ConfigReader } from '@backstage/config'; +import os from 'os'; +import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError, NotFoundError } from '../errors'; +import { + GitLabIntegration, + readGitLabIntegrationConfig, +} from '@backstage/integration'; const logger = getVoidLogger(); @@ -28,107 +37,440 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); -describe('GitlabUrlReader', () => { - const worker = setupServer(); +const gitlabProcessor = new GitlabUrlReader( + new GitLabIntegration( + readGitLabIntegrationConfig( + new ConfigReader({ + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', + }), + ), + ), + { treeResponseFactory }, +); +const hostedGitlabProcessor = new GitlabUrlReader( + new GitLabIntegration( + readGitLabIntegrationConfig( + new ConfigReader({ + host: 'gitlab.mycompany.com', + apiBaseUrl: 'https://gitlab.mycompany.com/api/v4', + baseUrl: 'https://gitlab.mycompany.com', + }), + ), + ), + { treeResponseFactory }, +); + +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + +describe('GitlabUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const worker = setupServer(); msw.setupDefaultHandlers(worker); - beforeEach(() => { - worker.use( - rest.get('*/api/v4/projects/:name', (_, res, ctx) => - res(ctx.status(200), ctx.json({ id: 12345 })), - ), - rest.get('*', (req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.getAllHeaders(), - }), + describe('implementation', () => { + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), ), - ), - ); - }); - - const createConfig = (token?: string) => - new ConfigReader( - { - integrations: { gitlab: [{ host: 'gitlab.com', token }] }, - }, - 'test-config', - ); - - it.each([ - // Project URLs - { - url: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - config: createConfig(), - response: expect.objectContaining({ - url: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - headers: expect.objectContaining({ - 'private-token': '', - }), - }), - }, - { - url: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - config: createConfig('0123456789'), - response: expect.objectContaining({ - url: - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - headers: expect.objectContaining({ - 'private-token': '0123456789', - }), - }), - }, - { - url: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup - config: createConfig(), - response: expect.objectContaining({ - url: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - }), - }, - - // Raw URLs - { - url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', - config: createConfig(), - response: expect.objectContaining({ - url: 'https://gitlab.example.com/a/b/raw/master/c.yaml', - }), - }, - ])('should handle happy path %#', async ({ url, config, response }) => { - const [{ reader }] = GitlabUrlReader.factory({ - config, - logger, - treeResponseFactory, + rest.get('*', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.getAllHeaders(), + }), + ), + ), + ); }); - const data = await reader.read(url); - const res = await JSON.parse(data.toString('utf-8')); - expect(res).toEqual(response); - }); + const createConfig = (token?: string) => + new ConfigReader( + { + integrations: { gitlab: [{ host: 'gitlab.com', token }] }, + }, + 'test-config', + ); - it.each([ - { - url: '', - config: createConfig(''), - error: - "Invalid type in config for key 'integrations.gitlab[0].token' in 'test-config', got empty-string, wanted string", - }, - ])('should handle error path %#', async ({ url, config, error }) => { - await expect(async () => { + it.each([ + // Project URLs + { + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '', + }), + }), + }, + { + url: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig('0123456789'), + response: expect.objectContaining({ + url: + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '0123456789', + }), + }), + }, + { + url: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }), + }, + + // Raw URLs + { + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }), + }, + ])('should handle happy path %#', async ({ url, config, response }) => { const [{ reader }] = GitlabUrlReader.factory({ config, logger, treeResponseFactory, }); - await reader.read(url); - }).rejects.toThrow(error); + + const data = await reader.read(url); + const res = await JSON.parse(data.toString('utf-8')); + expect(res).toEqual(response); + }); + + it.each([ + { + url: '', + config: createConfig(''), + error: + "Invalid type in config for key 'integrations.gitlab[0].token' in 'test-config', got empty-string, wanted string", + }, + ])('should handle error path %#', async ({ url, config, error }) => { + await expect(async () => { + const [{ reader }] = GitlabUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); + await reader.read(url); + }).rejects.toThrow(error); + }); + }); + + describe('readTree', () => { + 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/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), + ), + ), + 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 response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main', + ); + + const files = await response.files(); + expect(files.length).toBe(2); + + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('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://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', + (_, 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), + ), + ), + ); + + const response = await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.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 gitlabProcessor.readTree( + 'https://gitlab.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 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); + }); + }); + + describe('search', () => { + 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/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), + ), + ), + 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), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await gitlabProcessor.search( + 'https://gitlab.com/backstage/mock/tree/main/**/index.*', + ); + expect(result.etag).toBe('sha123abc'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://gitlab.com/backstage/mock/tree/main/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + gitlabProcessor.search( + 'https://gitlab.com/backstage/mock/tree/main/**/index.*', + { etag: 'sha123abc' }, + ), + ).rejects.toThrow(NotModifiedError); + }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index e2d3edfea2..66ceae05c6 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -15,42 +15,53 @@ */ import { - GitLabIntegrationConfig, - readGitLabIntegrationConfigs, + getGitLabFileFetchUrl, + getGitLabRequestOptions, + GitLabIntegration, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; +import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; +import { Readable } from 'stream'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + SearchOptions, + SearchResponse, + UrlReader, +} from './types'; export class GitlabUrlReader implements UrlReader { - static factory: ReaderFactory = ({ config }) => { - const configs = readGitLabIntegrationConfigs( - config.getOptionalConfigArray('integrations.gitlab') ?? [], - ); - return configs.map(options => { - const reader = new GitlabUrlReader(options); - const predicate = (url: URL) => url.host === options.host; + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const integrations = ScmIntegrations.fromConfig(config); + return integrations.gitlab.list().map(integration => { + const reader = new GitlabUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; - constructor(private readonly options: GitLabIntegrationConfig) {} + constructor( + private readonly integration: GitLabIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) {} async read(url: string): Promise { - // TODO(Rugvip): merged the old GitlabReaderProcessor in here and used - // the existence of /~/blob/ to switch the logic. Don't know if this - // makes sense and it might require some more work. - let builtUrl: URL; - if (url.includes('/-/blob/')) { - const projectID = await this.getProjectID(url); - builtUrl = this.buildProjectUrl(url, projectID); - } else { - builtUrl = this.buildRawUrl(url); - } + const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch( + builtUrl, + getGitLabRequestOptions(this.integration.config), + ); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -66,115 +77,109 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { - throw new Error('GitlabUrlReader does not implement readTree'); - } + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { ref, full_name, filepath } = parseGitUrl(url); - // Converts - // from: https://gitlab.example.com/a/b/blob/master/c.yaml - // to: https://gitlab.example.com/a/b/raw/master/c.yaml - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - repoName, - blobKeyword, - ...restOfPath - ] = url.pathname.split('/'); - - if ( - empty !== '' || - userOrOrg === '' || - repoName === '' || - blobKeyword !== 'blob' || - !restOfPath.join('/').match(/\.yaml$/) - ) { - throw new Error('Wrong GitLab URL'); - } - - // Replace 'blob' with 'raw' - url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join( - '/', - ); - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch - private buildProjectUrl(target: string, projectID: Number): URL { - try { - const url = new URL(target); - - const branchAndFilePath = url.pathname.split('/-/blob/')[1]; - - const [branch, ...filePath] = branchAndFilePath.split('/'); - - url.pathname = [ - '/api/v4/projects', - projectID, - 'repository/files', - encodeURIComponent(filePath.join('/')), - 'raw', - ].join('/'); - url.search = `?ref=${branch}`; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private async getProjectID(target: string): Promise { - const url = new URL(target); - - if ( - // absPaths to gitlab files should contain /-/blob - // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - !url.pathname.match(/\/\-\/blob\//) - ) { - throw new Error('Please provide full path to yaml file from Gitlab'); - } - try { - const repo = url.pathname.split('/-/blob/')[0]; - - // Find ProjectID from url - // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' - // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' - const repoIDLookup = new URL( - `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( - repo.replace(/^\//, ''), + // 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.integration.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, )}`, - ); - const response = await fetch( - repoIDLookup.toString(), - this.getRequestOptions(), - ); - const projectIDJson = await response.json(); - const projectID: Number = projectIDJson.id; - - return projectID; - } catch (e) { - throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`); + ).toString(), + getGitLabRequestOptions(this.integration.config), + ); + 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(); + + // ref is an empty string if no branch is set in provided url to readTree. + const branch = ref || projectGitlabResponseJson.default_branch; + + // Fetch the latest commit in the provided or default branch to compare against + // the provided sha. + const branchGitlabResponse = await fetch( + new URL( + `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/branches/${branch}`, + ).toString(), + getGitLabRequestOptions(this.integration.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.integration.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/archive.zip?sha=${branch}`, + getGitLabRequestOptions(this.integration.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.deps.treeResponseFactory.fromZipArchive({ + stream: (archiveGitLabResponse.body as unknown) as Readable, + subpath: filepath, + etag: commitSha, + filter: options?.filter, + }); } - private getRequestOptions(): RequestInit { + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); + return { - headers: { - ['PRIVATE-TOKEN']: this.options.token ?? '', - }, + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ url: `/${file.path}`, base: url }), + content: file.content, + })), }; } toString() { - const { host, token } = this.options; + const { host, token } = this.integration.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__/bitbucket-repo-with-commit-hash.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip new file mode 100644 index 0000000000..135860afd3 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip new file mode 100644 index 0000000000..be6b20d127 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip 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..e61a1df645 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -14,17 +14,18 @@ * 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'; +import { stripFirstDirectoryFromPath } from './util'; // Tar types for `Parse` is not a proper constructor, but it should be const TarParseStream = (Parse as unknown) as { new (): ParseStream }; @@ -41,6 +42,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 +55,8 @@ export class TarArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once @@ -75,14 +79,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 = stripFirstDirectoryFromPath(entry.path); + 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 +102,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 +144,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 +156,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 = stripFirstDirectoryFromPath(path); + 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..4aebff5c84 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -14,16 +14,17 @@ * 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'; +import { stripFirstDirectoryFromPath } from './util'; /** * Wraps a zip archive stream into a tree response reader. @@ -35,6 +36,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 +49,8 @@ export class ZipArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once @@ -57,18 +61,21 @@ export class ZipArchiveResponse implements ReadTreeResponse { this.read = true; } - private getPath(entry: Entry): string { - return entry.path.slice(this.subPath.length); + // 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 = stripFirstDirectoryFromPath(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 +95,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { if (this.shouldBeIncluded(entry)) { files.push({ - path: this.getPath(entry), + path: this.getInnerPath(stripFirstDirectoryFromPath(entry.path)), content: () => entry.buffer(), }); } else { @@ -112,7 +119,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 +135,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 +143,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( + stripFirstDirectoryFromPath(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/tree/util.ts b/packages/backend-common/src/reading/tree/util.ts new file mode 100644 index 0000000000..8e908a5e60 --- /dev/null +++ b/packages/backend-common/src/reading/tree/util.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +// 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 = /^[^\/]+\//; + +// Removes the first segment of a forward-slash-separated path +export function stripFirstDirectoryFromPath(path: string): string { + return path.replace(directoryNameRegex, ''); +} 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.test.ts b/packages/backend-common/src/scm/git.test.ts new file mode 100644 index 0000000000..9af080a782 --- /dev/null +++ b/packages/backend-common/src/scm/git.test.ts @@ -0,0 +1,321 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +jest.mock('isomorphic-git'); +jest.mock('isomorphic-git/http/node'); +jest.mock('fs-extra'); + +import * as isomorphic from 'isomorphic-git'; +import { Git } from './git'; +import http from 'isomorphic-git/http/node'; +import fs from 'fs-extra'; + +describe('Git', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + describe('add', () => { + it('should call isomorphic-git add with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const filepath = 'mockfile/path'; + + await git.add({ dir, filepath }); + + expect(isomorphic.add).toHaveBeenCalledWith({ + fs, + dir, + filepath, + }); + }); + }); + + describe('addRemote', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const remote = 'origin'; + const url = 'git@github.com/something/sads'; + + await git.addRemote({ dir, remote, url }); + + expect(isomorphic.addRemote).toHaveBeenCalledWith({ + fs, + dir, + remote, + url, + }); + }); + }); + + describe('commit', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const message = 'Inital Commit'; + const author = { + name: 'author', + email: 'test@backstage.io', + }; + const committer = { + name: 'comitter', + email: 'test@backstage.io', + }; + + await git.commit({ dir, message, author, committer }); + + expect(isomorphic.commit).toHaveBeenCalledWith({ + fs, + dir, + message, + author, + committer, + }); + }); + }); + + describe('clone', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const url = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.clone({ url, dir }); + + expect(isomorphic.clone).toHaveBeenCalledWith({ + fs, + http, + url, + dir, + singleBranch: true, + depth: 1, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const url = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.clone({ url, dir }); + + const { onAuth } = ((isomorphic.clone as unknown) as jest.Mock< + typeof isomorphic['clone'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('currentBranch', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const fullName = true; + const git = Git.fromAuth({}); + + await git.currentBranch({ dir, fullName }); + + expect(isomorphic.currentBranch).toHaveBeenCalledWith({ + fs, + dir, + fullname: true, + }); + + await git.currentBranch({ dir }); + + expect(isomorphic.currentBranch).toHaveBeenCalledWith({ + fs, + dir, + fullname: false, + }); + }); + }); + + describe('fetch', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const remote = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.fetch({ remote, dir }); + + expect(isomorphic.fetch).toHaveBeenCalledWith({ + fs, + http, + remote, + dir, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const remote = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.fetch({ remote, dir }); + + const { onAuth } = ((isomorphic.fetch as unknown) as jest.Mock< + typeof isomorphic['fetch'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('init', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + + const git = Git.fromAuth({}); + + await git.init({ dir }); + + expect(isomorphic.init).toHaveBeenCalledWith({ + fs, + dir, + }); + }); + }); + + describe('merge', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const author = { + name: 'author', + email: 'test@backstage.io', + }; + const committer = { + name: 'comitter', + email: 'test@backstage.io', + }; + const theirs = 'master'; + const ours = 'production'; + + const git = Git.fromAuth({}); + + await git.merge({ dir, theirs, ours, author, committer }); + + expect(isomorphic.merge).toHaveBeenCalledWith({ + fs, + dir, + ours, + theirs, + author, + committer, + }); + }); + }); + + describe('push', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const remote = 'origin'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.push({ dir, remote }); + + expect(isomorphic.push).toHaveBeenCalledWith({ + fs, + http, + remote, + dir, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const remote = 'origin'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.push({ remote, dir }); + + const { onAuth } = ((isomorphic.push as unknown) as jest.Mock< + typeof isomorphic['push'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('readCommit', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const sha = 'as43bd7'; + + const git = Git.fromAuth({}); + + await git.readCommit({ dir, sha }); + + expect(isomorphic.readCommit).toHaveBeenCalledWith({ + fs, + dir, + oid: sha, + }); + }); + }); + + describe('resolveRef', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const ref = 'as43bd7'; + + const git = Git.fromAuth({}); + + await git.resolveRef({ dir, ref }); + + expect(isomorphic.resolveRef).toHaveBeenCalledWith({ + fs, + dir, + ref, + }); + }); + }); +}); diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts new file mode 100644 index 0000000000..21aa9bf5d1 --- /dev/null +++ b/packages/backend-common/src/scm/git.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 git, { + ProgressCallback, + MergeResult, + ReadCommitResult, +} from 'isomorphic-git'; +import http from 'isomorphic-git/http/node'; +import fs from 'fs-extra'; +import { Logger } from 'winston'; + +/* +provider username password +GitHub 'x-access-token' token +BitBucket 'x-token-auth' token +GitLab 'oauth2' token +From : https://isomorphic-git.org/docs/en/onAuth with fix for GitHub + +Azure 'notempty' token +*/ +export class Git { + private constructor( + private readonly config: { + username?: string; + password?: string; + logger?: Logger; + }, + ) {} + + async add({ + dir, + filepath, + }: { + dir: string; + filepath: string; + }): Promise { + this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`); + + return git.add({ fs, dir, filepath }); + } + + async addRemote({ + dir, + url, + remote, + }: { + dir: string; + remote: string; + url: string; + }): Promise { + this.config.logger?.info( + `Creating new remote {dir=${dir},remote=${remote},url=${url}}`, + ); + return git.addRemote({ fs, dir, remote, url }); + } + + async commit({ + dir, + message, + author, + committer, + }: { + dir: string; + message: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + }): Promise { + this.config.logger?.info( + `Committing file to repo {dir=${dir},message=${message}}`, + ); + + return git.commit({ fs, dir, message, author, committer }); + } + + 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(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: this.onAuth, + }); + } + + // https://isomorphic-git.org/docs/en/currentBranch + async currentBranch({ + dir, + fullName, + }: { + dir: string; + fullName?: boolean; + }): Promise { + const fullname = fullName ?? false; + return git.currentBranch({ fs, dir, fullname }) as Promise< + string | undefined + >; + } + + // https://isomorphic-git.org/docs/en/fetch + async fetch({ + dir, + remote, + }: { + dir: string; + remote?: string; + }): Promise { + const remoteValue = remote ?? 'origin'; + this.config.logger?.info( + `Fetching remote=${remoteValue} for repository {dir=${dir}}`, + ); + await git.fetch({ + fs, + http, + dir, + remote: remoteValue, + onProgress: this.onProgressHandler(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: this.onAuth, + }); + } + + async init({ dir }: { dir: string }): Promise { + this.config.logger?.info(`Init git repository {dir=${dir}}`); + + return git.init({ + fs, + dir, + }); + } + + // https://isomorphic-git.org/docs/en/merge + async merge({ + dir, + theirs, + ours, + author, + committer, + }: { + dir: string; + theirs: string; + ours?: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + }): Promise { + this.config.logger?.info( + `Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`, + ); + + // If ours is undefined, current branch is used. + return git.merge({ + fs, + dir, + ours, + theirs, + author, + committer, + }); + } + + async push({ dir, remote }: { dir: string; remote: string }) { + this.config.logger?.info( + `Pushing directory to remote {dir=${dir},remote=${remote}}`, + ); + return git.push({ + fs, + dir, + http, + onProgress: this.onProgressHandler(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + remote: remote, + onAuth: this.onAuth, + }); + } + + // https://isomorphic-git.org/docs/en/readCommit + async readCommit({ + dir, + sha, + }: { + dir: string; + sha: string; + }): Promise { + return git.readCommit({ fs, dir, oid: sha }); + } + + // https://isomorphic-git.org/docs/en/resolveRef + async resolveRef({ + dir, + ref, + }: { + dir: string; + ref: string; + }): Promise { + return git.resolveRef({ fs, dir, ref }); + } + + private onAuth = () => ({ + username: this.config.username, + password: this.config.password, + }); + + private onProgressHandler = (): ProgressCallback => { + let currentPhase = ''; + + return event => { + if (currentPhase !== event.phase) { + currentPhase = event.phase; + this.config.logger?.info(event.phase); + } + const total = event.total + ? `${Math.round((event.loaded / event.total) * 100)}%` + : event.loaded; + this.config.logger?.debug(`status={${event.phase},total={${total}}}`); + }; + }; + + static fromAuth = ({ + username, + password, + logger, + }: { + username?: string; + password?: string; + logger?: Logger; + }) => new Git({ username, password, logger }); +} diff --git a/plugins/sentry-backend/src/index.ts b/packages/backend-common/src/scm/index.ts similarity index 94% rename from plugins/sentry-backend/src/index.ts rename to packages/backend-common/src/scm/index.ts index 7612c392a2..e967fffb44 100644 --- a/plugins/sentry-backend/src/index.ts +++ b/packages/backend-common/src/scm/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './service/router'; +export { Git } from './git'; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index b47f4ef7e5..07da975184 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -39,7 +39,6 @@ import { readHttpsSettings, } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; -import { metricsHandler } from './metrics'; export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces @@ -53,7 +52,7 @@ const DEFAULT_CSP = { 'frame-ancestors': ["'self'"], 'img-src': ["'self'", 'data:'], 'object-src': ["'none'"], - 'script-src': ["'self'"], + 'script-src': ["'self'", "'unsafe-eval'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], 'upgrade-insecure-requests': [] as string[], @@ -66,7 +65,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { private corsOptions: cors.CorsOptions | undefined; private cspOptions: Record | undefined; private httpsSettings: HttpsSettings | undefined; - private enableMetrics: boolean = true; private routers: [string, Router][]; // Reference to the module where builder is created - needed for hot module // reloading @@ -109,9 +107,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.httpsSettings = httpsSettings; } - // For now, configuration of metrics is a simple boolean and active by default - this.enableMetrics = backendConfig.getOptionalBoolean('metrics') !== false; - return this; } @@ -150,7 +145,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } - start(): Promise { + async start(): Promise { const app = express(); const { port, @@ -166,26 +161,23 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(cors(corsOptions)); } app.use(compression()); - if (this.enableMetrics) { - app.use(metricsHandler()); - } - app.use(requestLoggingHandler()); + app.use(requestLoggingHandler(logger)); for (const [root, route] of this.routers) { app.use(root, route); } app.use(notFoundHandler()); app.use(errorHandler()); + const server: http.Server = httpsSettings + ? await createHttpsServer(app, httpsSettings, logger) + : createHttpServer(app, logger); + return new Promise((resolve, reject) => { app.on('error', e => { logger.error(`Failed to start up on port ${port}, ${e}`); reject(e); }); - const server: http.Server = httpsSettings - ? createHttpsServer(app, httpsSettings, logger) - : createHttpServer(app, logger); - const stoppableServer = stoppable( server.listen(port, host, () => { logger.info(`Listening on ${host}:${port}`); diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts index 8a35f147ca..75252357d6 100644 --- a/packages/backend-common/src/service/lib/config.test.ts +++ b/packages/backend-common/src/service/lib/config.test.ts @@ -20,9 +20,7 @@ import { readCspOptions } from './config'; describe('config', () => { describe('readCspOptions', () => { it('reads valid values', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: ['value'] } } }, - ]); + const config = new ConfigReader({ csp: { key: ['value'] } }); expect(readCspOptions(config)).toEqual( expect.objectContaining({ key: ['value'], @@ -31,9 +29,7 @@ describe('config', () => { }); it('accepts false', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: false } } }, - ]); + const config = new ConfigReader({ csp: { key: false } }); expect(readCspOptions(config)).toEqual( expect.objectContaining({ key: false, @@ -42,9 +38,7 @@ describe('config', () => { }); it('rejects invalid value types', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: [4] } } }, - ]); + const config = new ConfigReader({ csp: { key: [4] } }); expect(() => readCspOptions(config)).toThrow(/wanted string-array/); }); }); diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index ea1a2b0887..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,15 +31,12 @@ export type CertificateReferenceOptions = { cert: string; }; -export type CertificateSigningOptions = { - algorithm: string; - size?: number; - days?: number; - attributes?: CertificateAttributes; +export type CertificateGenerationOptions = { + hostname: string; }; export type CertificateAttributes = { - commonName?: string; + commonName: string; }; /** @@ -193,8 +175,20 @@ export function readCspOptions( * ``` */ export function readHttpsSettings(config: Config): HttpsSettings | undefined { - const cc = config.getOptionalConfig('https'); + const https = config.getOptional('https'); + if (https === true) { + const baseUrl = config.getString('baseUrl'); + let hostname; + try { + hostname = new URL(baseUrl).hostname; + } catch (error) { + throw new Error(`Invalid backend.baseUrl "${baseUrl}"`); + } + return { certificate: { hostname } }; + } + + const cc = config.getOptionalConfig('https'); if (!cc) { return undefined; } diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-common/src/service/lib/hostFactory.ts index 8fab2fd4ab..db202a84ab 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -13,12 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import fs from 'fs-extra'; +import { resolve as resolvePath, dirname } from 'path'; import express from 'express'; import * as http from 'http'; import * as https from 'https'; import { Logger } from 'winston'; 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. * @@ -45,48 +52,155 @@ export function createHttpServer( * @returns A Https server instance * */ -export function createHttpsServer( +export async function createHttpsServer( app: express.Express, httpsSettings: HttpsSettings, logger?: Logger, -): http.Server { +): Promise { logger?.info('Initializing https server'); - const credentials: { key: string; cert: string } = { - key: '', - cert: '', - }; + let credentials: { key: string | Buffer; cert: string | Buffer }; - const signingOptions: any = httpsSettings?.certificate; - - if (signingOptions?.algorithm !== undefined) { - logger?.info('Generating self-signed certificate with attributes'); - - const certificateAttributes: Array = Object.entries( - signingOptions.attributes, - ).map(([name, value]) => ({ name, value })); - - // TODO: Create a type def for selfsigned. - const signatures = require('selfsigned').generate(certificateAttributes, { - algorithm: signingOptions?.algorithm, - keySize: signingOptions?.size || 2048, - days: signingOptions?.days || 30, - }); - - logger?.info('Bootstrapping self-signed certificate'); - - credentials.key = signatures.private; - credentials.cert = signatures.cert; + if ('hostname' in httpsSettings?.certificate) { + credentials = await getGeneratedCertificate( + httpsSettings.certificate.hostname, + logger, + ); } else { - logger?.info('Bootstrapping cert from config'); + logger?.info('Loading certificate from config'); - credentials.key = signingOptions?.key; - credentials.cert = signingOptions?.cert; + credentials = { + key: httpsSettings?.certificate?.key, + cert: httpsSettings?.certificate?.cert, + }; } - if (credentials.key === '' || credentials.cert === '') { - throw new Error('Invalid credentials'); + if (!credentials.key || !credentials.cert) { + throw new Error('Invalid HTTPS credentials'); } return https.createServer(credentials, app) as http.Server; } + +async function getGeneratedCertificate(hostname: string, logger?: Logger) { + const hasModules = await fs.pathExists('node_modules'); + let certPath; + if (hasModules) { + certPath = resolvePath( + 'node_modules/.cache/backstage-backend/dev-cert.pem', + ); + await fs.ensureDir(dirname(certPath)); + } else { + certPath = resolvePath('.dev-cert.pem'); + } + + let cert = undefined; + if (await fs.pathExists(certPath)) { + const stat = await fs.stat(certPath); + const ageMs = Date.now() - stat.ctimeMs; + if (stat.isFile() && ageMs < ALMOST_MONTH_IN_MS) { + cert = await fs.readFile(certPath); + } + } + + if (cert) { + logger?.info('Using existing self-signed certificate'); + return { + key: cert, + cert: cert, + }; + } + + logger?.info('Generating new self-signed certificate'); + const newCert = await createCertificate(hostname); + await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); + return newCert; +} + +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: 'sha256', + keySize: 2048, + days: 30, + extensions: [ + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true, + }, + { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + timeStamping: true, + }, + { + name: 'subjectAltName', + altNames: sans, + }, + ], + }; + + return new Promise<{ key: string; cert: string }>((resolve, reject) => + require('selfsigned').generate( + attributes, + params, + (err: Error, bundle: { private: string; cert: string }) => { + if (err) { + reject(err); + } else { + resolve({ key: bundle.private, cert: bundle.cert }); + } + }, + ), + ); +} diff --git a/packages/backend-common/src/service/lib/metrics.ts b/packages/backend-common/src/service/lib/metrics.ts deleted file mode 100644 index 37d441f53c..0000000000 --- a/packages/backend-common/src/service/lib/metrics.ts +++ /dev/null @@ -1,53 +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 prom from 'prom-client'; -import promBundle from 'express-prom-bundle'; -import { RequestHandler } from 'express'; -import * as url from 'url'; - -const rootRegEx = new RegExp('^/([^/]*)/.*'); -const apiRegEx = new RegExp('^/api/([^/]*)/.*'); - -export function normalizePath(req: any): string { - const path = url.parse(req.originalUrl || req.url).pathname || '/'; - - // Capture /api/ and the plugin name - if (apiRegEx.test(path)) { - return path.replace(apiRegEx, '/api/$1'); - } - - // Only the first path segment at root level - return path.replace(rootRegEx, '/$1'); -} - -/** - * Adds a /metrics endpoint, register default runtime metrics and instrument the router. - */ -export function metricsHandler(): RequestHandler { - // We can only initialize the metrics once and have to clean them up between hot reloads - prom.register.clear(); - - return promBundle({ - includeMethod: true, - includePath: true, - // Using includePath alone is problematic, as it will include path labels with high - // cardinality (e.g. path params). Instead we would have to template them. However, this - // is difficult, as every backend plugin might use different routes. Instead we only take - // the first directory of the path, to have at least an idea how each plugin performs: - normalizePath, - promClient: { collectDefaultMetrics: {} }, - }); -} diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 838ca7d302..84e51d6bcf 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,252 @@ # example-backend +## 0.2.17 + +### Patch Changes + +- Updated dependencies [a70af22a2] +- Updated dependencies [ec504e7b4] +- Updated dependencies [a5f42cf66] +- Updated dependencies [f37992797] +- Updated dependencies [bad21a085] +- Updated dependencies [1c06cb312] +- Updated dependencies [2499f6cde] +- Updated dependencies [a1f5e6545] + - @backstage/plugin-kubernetes-backend@0.2.7 + - @backstage/plugin-auth-backend@0.3.2 + - @backstage/plugin-scaffolder-backend@0.8.0 + - @backstage/plugin-techdocs-backend@0.6.2 + - @backstage/catalog-model@0.7.2 + - @backstage/plugin-app-backend@0.3.8 + - @backstage/plugin-catalog-backend@0.6.3 + - @backstage/config@0.1.3 + - example-app@0.2.17 + +## 0.2.15 + +### Patch Changes + +- Updated dependencies [1deb31141] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [77ad0003a] +- Updated dependencies [d2441aee3] +- Updated dependencies [727f0deec] +- Updated dependencies [fb53eb7cb] +- Updated dependencies [07bafa248] +- Updated dependencies [ffffea8e6] +- Updated dependencies [f3fbfb452] +- Updated dependencies [615103a63] +- Updated dependencies [84364b35c] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] +- Updated dependencies [82b2c11b6] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] + - @backstage/plugin-auth-backend@0.3.0 + - @backstage/plugin-scaffolder-backend@0.7.0 + - @backstage/plugin-catalog-backend@0.6.1 + - @backstage/plugin-app-backend@0.3.7 + - example-app@0.2.15 + - @backstage/backend-common@0.5.3 + - @backstage/plugin-techdocs-backend@0.6.0 + +## 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 + +- 7cfcd58ee: use node 14 for backend Dockerfile +- Updated dependencies [19554f6d6] +- Updated dependencies [33a82a713] +- Updated dependencies [5de26b9a6] +- Updated dependencies [30d6c78fb] +- Updated dependencies [5084e5039] +- Updated dependencies [a8573e53b] +- Updated dependencies [aed8f7f12] + - @backstage/plugin-scaffolder-backend@0.3.6 + - @backstage/plugin-catalog-backend@0.5.1 + - @backstage/plugin-techdocs-backend@0.5.0 + - example-app@0.2.8 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [c6eeefa35] +- Updated dependencies [fb386b760] +- Updated dependencies [c911061b7] +- Updated dependencies [7c3ffc0cd] +- Updated dependencies [dae4f3983] +- Updated dependencies [7b15cc271] +- Updated dependencies [e7496dc3e] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [8dd0a906d] +- Updated dependencies [4eafdec4a] +- Updated dependencies [6b37c95bf] +- Updated dependencies [8c31c681c] +- Updated dependencies [7b98e7fee] +- Updated dependencies [ac3560b42] +- Updated dependencies [94c65a9d4] +- Updated dependencies [0097057ed] + - @backstage/plugin-catalog-backend@0.5.0 + - @backstage/catalog-model@0.6.0 + - @backstage/plugin-techdocs-backend@0.4.0 + - @backstage/plugin-auth-backend@0.2.7 + - @backstage/backend-common@0.4.1 + - @backstage/plugin-scaffolder-backend@0.3.5 + - example-app@0.2.7 + - @backstage/plugin-kubernetes-backend@0.2.3 + +## 0.2.6 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [6e8bb3ac0] +- Updated dependencies [e708679d7] +- Updated dependencies [047c018c9] +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [38d63fbe1] +- Updated dependencies [1e22f8e0b] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/plugin-catalog-backend@0.4.0 + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/plugin-scaffolder-backend@0.3.4 + - @backstage/plugin-techdocs-backend@0.3.2 + - @backstage/catalog-model@0.5.0 + - example-app@0.2.6 + - @backstage/plugin-app-backend@0.3.3 + - @backstage/plugin-auth-backend@0.2.6 + - @backstage/plugin-graphql-backend@0.1.4 + - @backstage/plugin-kubernetes-backend@0.2.2 + - @backstage/plugin-proxy-backend@0.2.3 + - @backstage/plugin-rollbar-backend@0.1.5 + ## 0.2.5 ### Patch Changes diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 1aa5a355cf..acef405c8a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,16 +1,26 @@ -FROM node:12-buster +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` -WORKDIR /usr/src/app +FROM node:14-buster-slim + +WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json skeleton.tar ./ +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -# This will copy the contents of the dist-workspace when running the build-image command. -# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. -COPY . . +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ -CMD ["node", "packages/backend"] +CMD ["node", "packages/backend", "--config", "app-config.yaml"] diff --git a/packages/backend/package.json b/packages/backend/package.json index 9e635902e9..9220b71866 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,16 +1,25 @@ { "name": "example-backend", - "version": "0.2.5", + "version": "0.2.17", "main": "dist/index.cjs.js", "types": "src/index.ts", - "private": true, "license": "Apache-2.0", + "private": true, "engines": { "node": "12 || 14" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend" + }, + "keywords": [ + "backstage" + ], "scripts": { - "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image --build --tag example-backend", + "build": "backstage-cli backend:bundle", + "build-image": "docker build ../.. -f Dockerfile --tag example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -18,24 +27,25 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@backstage/plugin-app-backend": "^0.3.2", - "@backstage/plugin-auth-backend": "^0.2.5", - "@backstage/plugin-catalog-backend": "^0.3.0", - "@backstage/plugin-graphql-backend": "^0.1.3", - "@backstage/plugin-kubernetes-backend": "^0.2.1", - "@backstage/plugin-proxy-backend": "^0.2.2", - "@backstage/plugin-rollbar-backend": "^0.1.4", - "@backstage/plugin-scaffolder-backend": "^0.3.3", - "@backstage/plugin-sentry-backend": "^0.1.3", - "@backstage/plugin-techdocs-backend": "^0.3.1", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", + "@backstage/backend-common": "^0.5.3", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/plugin-app-backend": "^0.3.8", + "@backstage/plugin-auth-backend": "^0.3.2", + "@backstage/plugin-catalog-backend": "^0.6.3", + "@backstage/plugin-graphql-backend": "^0.1.5", + "@backstage/plugin-kubernetes-backend": "^0.2.7", + "@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.8.0", + "@backstage/plugin-techdocs-backend": "^0.6.2", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", - "dockerode": "^3.2.0", - "example-app": "^0.2.5", + "dockerode": "^3.2.1", + "example-app": "^0.2.17", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -45,10 +55,12 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@types/dockerode": "^2.5.32", + "@backstage/cli": "^0.6.2", + "@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 b74954ecc5..81d0bb96d2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -25,22 +25,22 @@ import Router from 'express-promise-router'; import { createServiceBuilder, - loadBackendConfig, getRootLogger, - useHotMemoize, + loadBackendConfig, notFoundHandler, SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, + useHotMemoize, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; 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 sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; @@ -76,9 +76,9 @@ async function main() { const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); - const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); @@ -86,10 +86,10 @@ async function main() { apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); - apiRouter.use('/sentry', await sentry(sentryEnv)); apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/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/app.ts b/packages/backend/src/plugins/app.ts index 637af80974..5ee616b62f 100644 --- a/packages/backend/src/plugins/app.ts +++ b/packages/backend/src/plugins/app.ts @@ -15,12 +15,13 @@ */ import { createRouter } from '@backstage/plugin-app-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 913c8b783d..2b1c85f052 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -15,6 +15,7 @@ */ import { createRouter } from '@backstage/plugin-auth-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -22,6 +23,6 @@ export default async function createPlugin({ database, config, discovery, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 1338fab440..eaca42717c 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -20,9 +20,12 @@ import { createRouter, runPeriodically, } from '@backstage/plugin-catalog-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; -export default async function createPlugin(env: PluginEnvironment) { +export default async function createPlugin( + env: PluginEnvironment, +): Promise { const builder = new CatalogBuilder(env); const { entitiesCatalog, diff --git a/packages/backend/src/plugins/graphql.ts b/packages/backend/src/plugins/graphql.ts index d52e28f9cb..c7f5d6e072 100644 --- a/packages/backend/src/plugins/graphql.ts +++ b/packages/backend/src/plugins/graphql.ts @@ -13,29 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES 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-graphql-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, diff --git a/packages/backend/src/plugins/healthcheck.ts b/packages/backend/src/plugins/healthcheck.ts index 3dd9a67827..8ecae6be87 100644 --- a/packages/backend/src/plugins/healthcheck.ts +++ b/packages/backend/src/plugins/healthcheck.ts @@ -15,8 +15,11 @@ */ import { createStatusCheckRouter } from '@backstage/backend-common'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; -export default async function createRouter({ logger }: PluginEnvironment) { +export default async function createRouter({ + logger, +}: PluginEnvironment): Promise { return await createStatusCheckRouter({ logger, path: '/healthcheck' }); } diff --git a/packages/backend/src/plugins/kafka.ts b/packages/backend/src/plugins/kafka.ts new file mode 100644 index 0000000000..d5b5857027 --- /dev/null +++ b/packages/backend/src/plugins/kafka.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-kafka-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment): Promise { + return await createRouter({ logger, config }); +} diff --git a/packages/backend/src/plugins/kubernetes.ts b/packages/backend/src/plugins/kubernetes.ts index 7906765533..131c5c8eee 100644 --- a/packages/backend/src/plugins/kubernetes.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -15,11 +15,12 @@ */ import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/proxy.ts b/packages/backend/src/plugins/proxy.ts index 867e742dc0..8c5e3284c0 100644 --- a/packages/backend/src/plugins/proxy.ts +++ b/packages/backend/src/plugins/proxy.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -// @ts-ignore import { createRouter } from '@backstage/plugin-proxy-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, discovery, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, discovery }); } diff --git a/packages/backend/src/plugins/rollbar.ts b/packages/backend/src/plugins/rollbar.ts index c6a2c27e9b..b510346af5 100644 --- a/packages/backend/src/plugins/rollbar.ts +++ b/packages/backend/src/plugins/rollbar.ts @@ -15,11 +15,12 @@ */ import { createRouter } from '@backstage/plugin-rollbar-backend'; +import { Router } from 'express'; import type { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 5d36d508a5..1b97f735a0 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -14,26 +14,29 @@ * limitations under the License. */ +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; import { CookieCutter, + CreateReactAppTemplater, 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'; +import { Router } from 'express'; +import type { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { + database, +}: PluginEnvironment): Promise { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); @@ -43,7 +46,7 @@ export default async function createPlugin({ const dockerClient = new Docker(); const discovery = SingleHostDiscovery.fromConfig(config); - const entityClient = new CatalogEntityClient({ discovery }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ preparers, @@ -52,6 +55,7 @@ export default async function createPlugin({ logger, config, dockerClient, - entityClient, + database, + catalogClient, }); } diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index de48280e64..9883f2afbc 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -13,45 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { createRouter, - DirectoryPreparer, - Preparers, Generators, - LocalPublish, - TechdocsGenerator, - CommonGitPreparer, - UrlPreparer, + Preparers, + Publisher, } from '@backstage/plugin-techdocs-backend'; -import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, discovery, reader, -}: PluginEnvironment) { - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); - generators.register('techdocs', techdocsGenerator); +}: PluginEnvironment): Promise { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); - const preparers = new Preparers(); + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); - const directoryPreparer = new DirectoryPreparer(logger); - preparers.register('dir', directoryPreparer); - - const commonGitPreparer = new CommonGitPreparer(logger); - preparers.register('github', commonGitPreparer); - preparers.register('gitlab', commonGitPreparer); - preparers.register('azure/api', commonGitPreparer); - - const urlPreparer = new UrlPreparer(reader, logger); - preparers.register('url', urlPreparer); - - const publisher = new LocalPublish(logger, discovery); + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 3ea3b496d7..87d6e2cff0 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,39 @@ # @backstage/catalog-client +## 0.3.6 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 72b96e880: Add the `presence` argument to the `CatalogApi` to be able to register optional locations. + +## 0.3.5 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.3.2 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index a343cae716..1d0e890f4e 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.2", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/catalog-client" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", + "@backstage/catalog-model": "^0.7.1", + "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.0", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 6369f95b76..19da45e4ec 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -21,6 +21,7 @@ import { CatalogClient } from './CatalogClient'; import { CatalogListResponse, DiscoveryApi } from './types'; const server = setupServer(); +const token = 'fake-token'; const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discoveryApi: DiscoveryApi = { async getBaseUrl(_pluginId) { @@ -71,7 +72,7 @@ describe('CatalogClient', () => { }); it('should entities from correct endpoint', async () => { - const response = await client.getEntities(); + const response = await client.getEntities({}, { token }); expect(response).toEqual(defaultResponse); }); @@ -85,13 +86,16 @@ describe('CatalogClient', () => { }), ); - const response = await client.getEntities({ - filter: { - a: '1', - b: ['2', '3'], - ö: '=', + const response = await client.getEntities( + { + filter: { + a: '1', + b: ['2', '3'], + ö: '=', + }, }, - }); + { token }, + ); expect(response.items).toEqual([]); }); @@ -106,11 +110,61 @@ describe('CatalogClient', () => { }), ); - const response = await client.getEntities({ - fields: ['a.b', 'ö'], - }); + const response = await client.getEntities( + { + fields: ['a.b', 'ö'], + }, + { token }, + ); expect(response.items).toEqual([]); }); }); + + describe('getLocationById', () => { + const defaultResponse = { + data: { + id: '42', + }, + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + }); + + it('should locations from correct endpoint', async () => { + const response = await client.getLocationById('42', { token }); + expect(response).toEqual(defaultResponse); + }); + + it('forwards authorization token', async () => { + expect.assertions(1); + + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json(defaultResponse)); + }), + ); + + await client.getLocationById('42', { token }); + }); + + it('skips authorization header if token is omitted', async () => { + expect.assertions(1); + + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => { + expect(req.headers.get('authorization')).toBeNull(); + return res(ctx.json(defaultResponse)); + }), + ); + + await client.getLocationById('42'); + }); + }); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 362b1e71da..04206edcdb 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -24,6 +24,7 @@ import fetch from 'cross-fetch'; import { AddLocationRequest, AddLocationResponse, + CatalogRequestOptions, CatalogApi, CatalogEntitiesRequest, CatalogListResponse, @@ -37,12 +38,16 @@ export class CatalogClient implements CatalogApi { this.discoveryApi = options.discoveryApi; } - async getLocationById(id: String): Promise { - return await this.getOptional(`/locations/${id}`); + async getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise { + return await this.getOptional(`/locations/${id}`, options); } async getEntities( request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, ): Promise> { const { filter = {}, fields = [] } = request ?? {}; const params: string[] = []; @@ -62,20 +67,28 @@ export class CatalogClient implements CatalogApi { } const query = params.length ? `?${params.join('&')}` : ''; - const entities: Entity[] = await this.getRequired(`/entities${query}`); + const entities: Entity[] = await this.getRequired( + `/entities${query}`, + options, + ); return { items: entities }; } - async getEntityByName(compoundName: EntityName): Promise { + async getEntityByName( + compoundName: EntityName, + options?: CatalogRequestOptions, + ): Promise { const { kind, namespace = 'default', name } = compoundName; - return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); + return this.getOptional( + `/entities/by-name/${kind}/${namespace}/${name}`, + options, + ); } - async addLocation({ - type = 'url', - target, - dryRun, - }: AddLocationRequest): Promise { + async addLocation( + { type = 'url', target, dryRun, presence }: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ dryRun ? '?dryRun=true' : '' @@ -83,9 +96,10 @@ export class CatalogClient implements CatalogApi { { headers: { 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), }, method: 'POST', - body: JSON.stringify({ type, target }), + body: JSON.stringify({ type, target, presence }), }, ); @@ -110,18 +124,30 @@ export class CatalogClient implements CatalogApi { }; } - async getLocationByEntity(entity: Entity): Promise { + async getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise { const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - const all: { data: Location }[] = await this.getRequired('/locations'); + const all: { data: Location }[] = await this.getRequired( + '/locations', + options, + ); return all .map(r => r.data) .find(l => locationCompound === `${l.type}:${l.target}`); } - async removeEntityByUid(uid: string): Promise { + async removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, method: 'DELETE', }, ); @@ -138,9 +164,16 @@ export class CatalogClient implements CatalogApi { // Private methods // - private async getRequired(path: string): Promise { + private async getRequired( + path: string, + options?: CatalogRequestOptions, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, + }); if (!response.ok) { const payload = await response.text(); @@ -151,9 +184,16 @@ export class CatalogClient implements CatalogApi { return await response.json(); } - private async getOptional(path: string): Promise { + private async getOptional( + path: string, + options?: CatalogRequestOptions, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, + }); if (!response.ok) { if (response.status === 404) { diff --git a/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts index 9bd13f9b7c..c5a626e25b 100644 --- a/packages/catalog-client/src/index.ts +++ b/packages/catalog-client/src/index.ts @@ -15,4 +15,10 @@ */ export { CatalogClient } from './CatalogClient'; -export type { CatalogApi } from './types'; +export type { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, +} from './types'; diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts index e72317d444..e460843657 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types.ts @@ -25,21 +25,42 @@ export type CatalogListResponse = { items: T[]; }; +export type CatalogRequestOptions = { + token?: string; +}; + export interface CatalogApi { - getLocationById(id: String): Promise; - getEntityByName(name: EntityName): Promise; + getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise; + getEntityByName( + name: EntityName, + options?: CatalogRequestOptions, + ): Promise; getEntities( request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, ): Promise>; - addLocation(location: AddLocationRequest): Promise; - getLocationByEntity(entity: Entity): Promise; - removeEntityByUid(uid: string): Promise; + addLocation( + location: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise; + getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise; + removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise; } export type AddLocationRequest = { type?: string; target: string; dryRun?: boolean; + presence?: 'optional' | 'required'; }; export type AddLocationResponse = { diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index be8e4a1d70..3351ed317a 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,90 @@ # @backstage/catalog-model +## 0.7.2 + +### Patch Changes + +- bad21a085: Implement annotations for customising Entity URLs in the Catalog pages. +- Updated dependencies [a1f5e6545] + - @backstage/config@0.1.3 + +## 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 + +- ac3560b42: Remove `implementsApis` from `Component` entities. Deprecation happened in [#3449](https://github.com/backstage/backstage/pull/3449). + Use `providesApis` instead. + +### Patch Changes + +- c911061b7: Introduce a `profile` section for group entities that can optional include a + `displayName`, `email` and `picture`. +- 0e6298f7e: Ignore relations when comparing entities. This stops the refresh loop from rewriting entities over and over. + +## 0.5.0 + +### Minor Changes + +- 83b6e0c1f: Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity. + + See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details. + +### Patch Changes + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + ## 0.4.0 ### Minor Changes diff --git a/packages/catalog-model/examples-relative/README.md b/packages/catalog-model/examples-relative/README.md deleted file mode 100644 index 3ed63a0e00..0000000000 --- a/packages/catalog-model/examples-relative/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Examples with relative paths - -These examples use Location entities with relative paths -[see #3513](https://github.com/backstage/backstage/pull/3513). - -The `examples` sibling directory will be replaced with these contents no sooner -than December 16 2020, to give consumers time to adopt the above change. - -The contents of this directory is here for consumers who are on the edge and -want to use the new facility before December 16. diff --git a/packages/catalog-model/examples-relative/acme-corp.yaml b/packages/catalog-model/examples-relative/acme-corp.yaml deleted file mode 100644 index 6449d548e0..0000000000 --- a/packages/catalog-model/examples-relative/acme-corp.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Location -metadata: - name: acme-corp - description: A collection of all Backstage example Groups -spec: - targets: - - ./acme/org.yaml diff --git a/packages/catalog-model/examples-relative/acme/backstage-group.yaml b/packages/catalog-model/examples-relative/acme/backstage-group.yaml deleted file mode 100644 index f992d155bc..0000000000 --- a/packages/catalog-model/examples-relative/acme/backstage-group.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: backstage - description: The backstage sub-department -spec: - type: sub-department - parent: infrastructure - ancestors: [infrastructure, acme-corp] - children: [team-a, team-b] - descendants: [team-a, team-b] diff --git a/packages/catalog-model/examples-relative/acme/boxoffice-group.yaml b/packages/catalog-model/examples-relative/acme/boxoffice-group.yaml deleted file mode 100644 index 0be1fe58cb..0000000000 --- a/packages/catalog-model/examples-relative/acme/boxoffice-group.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: boxoffice - description: The boxoffice sub-department -spec: - type: sub-department - parent: infrastructure - ancestors: [infrastructure, acme-corp] - children: [team-c, team-d] - descendants: [team-c, team-d] diff --git a/packages/catalog-model/examples-relative/acme/infrastructure-group.yaml b/packages/catalog-model/examples-relative/acme/infrastructure-group.yaml deleted file mode 100644 index 2341782944..0000000000 --- a/packages/catalog-model/examples-relative/acme/infrastructure-group.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: infrastructure - description: The infra department -spec: - type: department - parent: acme-corp - ancestors: [acme-corp] - children: [backstage, boxoffice] - descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d] diff --git a/packages/catalog-model/examples-relative/acme/org.yaml b/packages/catalog-model/examples-relative/acme/org.yaml deleted file mode 100644 index fe368962b1..0000000000 --- a/packages/catalog-model/examples-relative/acme/org.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: acme-corp - description: The acme-corp organization -spec: - type: organization - ancestors: [] - children: [infrastructure] - descendants: - [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d] ---- -apiVersion: backstage.io/v1alpha1 -kind: Location -metadata: - name: example-groups - description: A collection of all Backstage example Groups -spec: - targets: - - ./infrastructure-group.yaml - - ./boxoffice-group.yaml - - ./backstage-group.yaml - - ./team-a-group.yaml - - ./team-b-group.yaml - - ./team-c-group.yaml - - ./team-d-group.yaml diff --git a/packages/catalog-model/examples-relative/acme/team-a-group.yaml b/packages/catalog-model/examples-relative/acme/team-a-group.yaml deleted file mode 100644 index 8dbf0bb839..0000000000 --- a/packages/catalog-model/examples-relative/acme/team-a-group.yaml +++ /dev/null @@ -1,58 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: team-a - description: Team A -spec: - type: team - parent: backstage - ancestors: [backstage, infrastructure, acme-corp] - children: [] - descendants: [] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: breanna.davison -spec: - profile: - displayName: Breanna Davison - email: breanna-davison@example.com - picture: https://example.com/staff/breanna.jpeg - memberOf: [team-a] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: janelle.dawe -spec: - profile: - displayName: Janelle Dawe - email: janelle-dawe@example.com - picture: https://example.com/staff/janelle.jpeg - memberOf: [team-a] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: nigel.manning -spec: - profile: - displayName: Nigel Manning - email: nigel-manning@example.com - picture: https://example.com/staff/nigel.jpeg - memberOf: [team-a] ---- -# This user is added as an example, to make it more easy for the "Guest" -# sign-in option to demonstrate some entities being owned. In a regular org, -# a guest user would probably not be registered like this. -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: guest -spec: - profile: - displayName: Guest User - email: guest@example.com - picture: https://example.com/staff/the-ceos-dog.jpeg - memberOf: [team-a] diff --git a/packages/catalog-model/examples-relative/acme/team-b-group.yaml b/packages/catalog-model/examples-relative/acme/team-b-group.yaml deleted file mode 100644 index 00e9e41d80..0000000000 --- a/packages/catalog-model/examples-relative/acme/team-b-group.yaml +++ /dev/null @@ -1,66 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: team-b - description: Team B -spec: - type: team - parent: backstage - ancestors: [backstage, infrastructure, acme-corp] - children: [] - descendants: [] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: amelia.park -spec: - profile: - displayName: Amelia Park - email: amelia-park@example.com - picture: https://example.com/staff/amelia.jpeg - memberOf: [team-b] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: colette.brock -spec: - profile: - displayName: Colette Brock - email: colette-brock@example.com - picture: https://example.com/staff/colette.jpeg - memberOf: [team-b] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: jenny.doe -spec: - profile: - displayName: Jenny Doe - email: jenny-doe@example.com - picture: https://example.com/staff/jenny.jpeg - memberOf: [team-b] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: jonathon.page -spec: - profile: - displayName: Jonathon Page - email: jonathon-page@example.com - picture: https://example.com/staff/jonathon.jpeg - memberOf: [team-b] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: justine.barrow -spec: - profile: - displayName: Justine Barrow - email: justine-barrow@example.com - picture: https://example.com/staff/justine.jpeg - memberOf: [team-b] diff --git a/packages/catalog-model/examples-relative/acme/team-c-group.yaml b/packages/catalog-model/examples-relative/acme/team-c-group.yaml deleted file mode 100644 index 0f97f69cb3..0000000000 --- a/packages/catalog-model/examples-relative/acme/team-c-group.yaml +++ /dev/null @@ -1,66 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: team-c - description: Team C -spec: - type: team - parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] - children: [] - descendants: [] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: calum.leavy -spec: - profile: - displayName: Calum Leavy - email: calum-leavy@example.com - picture: https://example.com/staff/calum.jpeg - memberOf: [team-c] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: frank.tiernan -spec: - profile: - displayName: Frank Tiernan - email: frank-tiernan@example.com - picture: https://example.com/staff/frank.jpeg - memberOf: [team-c] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: peadar.macmahon -spec: - profile: - displayName: Peadar MacMahon - email: peadar-macmahon@example.com - picture: https://example.com/staff/peadar.jpeg - memberOf: [team-c] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: sarah.gilroy -spec: - profile: - displayName: Sarah Gilroy - email: sarah-gilroy@example.com - picture: https://example.com/staff/sarah.jpeg - memberOf: [team-c] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: tara.macgovern -spec: - profile: - displayName: Tara MacGovern - email: tara-macgovern@example.com - picture: https://example.com/staff/tara.jpeg - memberOf: [team-c] diff --git a/packages/catalog-model/examples-relative/acme/team-d-group.yaml b/packages/catalog-model/examples-relative/acme/team-d-group.yaml deleted file mode 100644 index 5a1a53a2e6..0000000000 --- a/packages/catalog-model/examples-relative/acme/team-d-group.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: team-d - description: Team D -spec: - type: team - parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] - children: [] - descendants: [] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: eva.macdowell -spec: - profile: - displayName: Eva MacDowell - email: eva-macdowell@example.com - picture: https://example.com/staff/eva.jpeg - memberOf: [team-d] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: lucy.sheehan -spec: - profile: - displayName: Lucy Sheehan - email: lucy-sheehan@example.com - picture: https://example.com/staff/lucy.jpeg - memberOf: [team-d] diff --git a/packages/catalog-model/examples-relative/all-apis.yaml b/packages/catalog-model/examples-relative/all-apis.yaml deleted file mode 100644 index 20752faf97..0000000000 --- a/packages/catalog-model/examples-relative/all-apis.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Location -metadata: - name: example-apis - description: A collection of all Backstage example APIs -spec: - targets: - - ./apis/hello-world-api.yaml - - ./apis/petstore-api.yaml - - ./apis/spotify-api.yaml - - ./apis/streetlights-api.yaml - - ./apis/swapi-graphql.yaml diff --git a/packages/catalog-model/examples-relative/all-components.yaml b/packages/catalog-model/examples-relative/all-components.yaml deleted file mode 100644 index f29a7378a0..0000000000 --- a/packages/catalog-model/examples-relative/all-components.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Location -metadata: - name: example-components - description: A collection of all Backstage example components -spec: - targets: - - ./components/artist-lookup-component.yaml - - ./components/petstore-component.yaml - - ./components/playback-order-component.yaml - - ./components/podcast-api-component.yaml - - ./components/queue-proxy-component.yaml - - ./components/searcher-component.yaml - - ./components/playback-lib-component.yaml - - ./components/www-artist-component.yaml - - ./components/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples-relative/apis/hello-world-api.yaml b/packages/catalog-model/examples-relative/apis/hello-world-api.yaml deleted file mode 100644 index 0f9786329f..0000000000 --- a/packages/catalog-model/examples-relative/apis/hello-world-api.yaml +++ /dev/null @@ -1,48 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: hello-world - description: Hello World example for gRPC -spec: - type: grpc - lifecycle: deprecated - owner: team-c - definition: | - // Copyright 2015 gRPC authors. - // - // Licensed under the Apache License, Version 2.0 (the "License"); - // you may not use this file except in compliance with the License. - // You may obtain a copy of the License at - // - // http://www.apache.org/licenses/LICENSE-2.0 - // - // Unless required by applicable law or agreed to in writing, software - // distributed under the License is distributed on an "AS IS" BASIS, - // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - // See the License for the specific language governing permissions and - // limitations under the License. - - syntax = "proto3"; - - option java_multiple_files = true; - option java_package = "io.grpc.examples.helloworld"; - option java_outer_classname = "HelloWorldProto"; - option objc_class_prefix = "HLW"; - - package helloworld; - - // The greeting service definition. - service Greeter { - // Sends a greeting - rpc SayHello (HelloRequest) returns (HelloReply) {} - } - - // The request message containing the user's name. - message HelloRequest { - string name = 1; - } - - // The response message containing the greetings - message HelloReply { - string message = 1; - } diff --git a/packages/catalog-model/examples-relative/apis/spotify-api.yaml b/packages/catalog-model/examples-relative/apis/spotify-api.yaml deleted file mode 100644 index 0b8e80b86e..0000000000 --- a/packages/catalog-model/examples-relative/apis/spotify-api.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: spotify - description: The Spotify web API - tags: - - spotify - - rest - annotations: - # The annotation is deprecated, we use placeholders (see below) instead, remove it later. - backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml' -spec: - type: openapi - lifecycle: production - owner: team-a - definition: - $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml diff --git a/packages/catalog-model/examples-relative/apis/streetlights-api.yaml b/packages/catalog-model/examples-relative/apis/streetlights-api.yaml deleted file mode 100644 index 725811d0cc..0000000000 --- a/packages/catalog-model/examples-relative/apis/streetlights-api.yaml +++ /dev/null @@ -1,221 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: streetlights - description: The Smartylighting Streetlights API allows you to remotely manage the city lights. - tags: - - mqtt -spec: - type: asyncapi - lifecycle: production - owner: team-c - definition: | - asyncapi: 2.0.0 - info: - title: Streetlights API - version: '1.0.0' - description: | - The Smartylighting Streetlights API allows you to remotely manage the city lights. - - ### Check out its awesome features: - - * Turn a specific streetlight on/off 🌃 - * Dim a specific streetlight 😎 - * Receive real-time information about environmental lighting conditions 📈 - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0 - - servers: - production: - url: api.streetlights.smartylighting.com:{port} - protocol: mqtt - description: Test broker - variables: - port: - description: Secure connection (TLS) is available through port 8883. - default: '1883' - enum: - - '1883' - - '8883' - security: - - apiKey: [] - - supportedOauthFlows: - - streetlights:on - - streetlights:off - - streetlights:dim - - openIdConnectWellKnown: [] - - defaultContentType: application/json - - channels: - smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured: - description: The topic on which measured values may be produced and consumed. - parameters: - streetlightId: - $ref: '#/components/parameters/streetlightId' - subscribe: - summary: Receive information about environmental lighting conditions of a particular streetlight. - operationId: receiveLightMeasurement - traits: - - $ref: '#/components/operationTraits/kafka' - message: - $ref: '#/components/messages/lightMeasured' - - smartylighting/streetlights/1/0/action/{streetlightId}/turn/on: - parameters: - streetlightId: - $ref: '#/components/parameters/streetlightId' - publish: - operationId: turnOn - traits: - - $ref: '#/components/operationTraits/kafka' - message: - $ref: '#/components/messages/turnOnOff' - - smartylighting/streetlights/1/0/action/{streetlightId}/turn/off: - parameters: - streetlightId: - $ref: '#/components/parameters/streetlightId' - publish: - operationId: turnOff - traits: - - $ref: '#/components/operationTraits/kafka' - message: - $ref: '#/components/messages/turnOnOff' - - smartylighting/streetlights/1/0/action/{streetlightId}/dim: - parameters: - streetlightId: - $ref: '#/components/parameters/streetlightId' - publish: - operationId: dimLight - traits: - - $ref: '#/components/operationTraits/kafka' - message: - $ref: '#/components/messages/dimLight' - - components: - messages: - lightMeasured: - name: lightMeasured - title: Light measured - summary: Inform about environmental lighting conditions for a particular streetlight. - contentType: application/json - traits: - - $ref: '#/components/messageTraits/commonHeaders' - payload: - $ref: "#/components/schemas/lightMeasuredPayload" - turnOnOff: - name: turnOnOff - title: Turn on/off - summary: Command a particular streetlight to turn the lights on or off. - traits: - - $ref: '#/components/messageTraits/commonHeaders' - payload: - $ref: "#/components/schemas/turnOnOffPayload" - dimLight: - name: dimLight - title: Dim light - summary: Command a particular streetlight to dim the lights. - traits: - - $ref: '#/components/messageTraits/commonHeaders' - payload: - $ref: "#/components/schemas/dimLightPayload" - - schemas: - lightMeasuredPayload: - type: object - properties: - lumens: - type: integer - minimum: 0 - description: Light intensity measured in lumens. - sentAt: - $ref: "#/components/schemas/sentAt" - turnOnOffPayload: - type: object - properties: - command: - type: string - enum: - - on - - off - description: Whether to turn on or off the light. - sentAt: - $ref: "#/components/schemas/sentAt" - dimLightPayload: - type: object - properties: - percentage: - type: integer - description: Percentage to which the light should be dimmed to. - minimum: 0 - maximum: 100 - sentAt: - $ref: "#/components/schemas/sentAt" - sentAt: - type: string - format: date-time - description: Date and time when the message was sent. - - securitySchemes: - apiKey: - type: apiKey - in: user - description: Provide your API key as the user and leave the password empty. - supportedOauthFlows: - type: oauth2 - description: Flows to support OAuth 2.0 - flows: - implicit: - authorizationUrl: 'https://authserver.example/auth' - scopes: - 'streetlights:on': Ability to switch lights on - 'streetlights:off': Ability to switch lights off - 'streetlights:dim': Ability to dim the lights - password: - tokenUrl: 'https://authserver.example/token' - scopes: - 'streetlights:on': Ability to switch lights on - 'streetlights:off': Ability to switch lights off - 'streetlights:dim': Ability to dim the lights - clientCredentials: - tokenUrl: 'https://authserver.example/token' - scopes: - 'streetlights:on': Ability to switch lights on - 'streetlights:off': Ability to switch lights off - 'streetlights:dim': Ability to dim the lights - authorizationCode: - authorizationUrl: 'https://authserver.example/auth' - tokenUrl: 'https://authserver.example/token' - refreshUrl: 'https://authserver.example/refresh' - scopes: - 'streetlights:on': Ability to switch lights on - 'streetlights:off': Ability to switch lights off - 'streetlights:dim': Ability to dim the lights - openIdConnectWellKnown: - type: openIdConnect - openIdConnectUrl: 'https://authserver.example/.well-known' - - parameters: - streetlightId: - description: The ID of the streetlight. - schema: - type: string - - messageTraits: - commonHeaders: - headers: - type: object - properties: - my-app-header: - type: integer - minimum: 0 - maximum: 100 - - operationTraits: - kafka: - bindings: - kafka: - clientId: my-app-id diff --git a/packages/catalog-model/examples-relative/apis/swapi-graphql.yaml b/packages/catalog-model/examples-relative/apis/swapi-graphql.yaml deleted file mode 100644 index 0c1f7af5a4..0000000000 --- a/packages/catalog-model/examples-relative/apis/swapi-graphql.yaml +++ /dev/null @@ -1,1176 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: starwars-graphql - description: SWAPI GraphQL Schema -spec: - type: graphql - lifecycle: production - owner: team-b - definition: | - schema { - query: Root - } - - """A single film.""" - type Film implements Node { - """The title of this film.""" - title: String - - """The episode number of this film.""" - episodeID: Int - - """The opening paragraphs at the beginning of this film.""" - openingCrawl: String - - """The name of the director of this film.""" - director: String - - """The name(s) of the producer(s) of this film.""" - producers: [String] - - """The ISO 8601 date format of film release at original creator country.""" - releaseDate: String - speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection - starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection - vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection - characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection - planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type FilmCharactersConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmCharactersEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - characters: [Person] - } - - """An edge in a connection.""" - type FilmCharactersEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmPlanetsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmPlanetsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - planets: [Planet] - } - - """An edge in a connection.""" - type FilmPlanetsEdge { - """The item at the end of the edge""" - node: Planet - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type FilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmSpeciesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmSpeciesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - species: [Species] - } - - """An edge in a connection.""" - type FilmSpeciesEdge { - """The item at the end of the edge""" - node: Species - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmStarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmStarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type FilmStarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmVehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmVehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type FilmVehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } - - """An object with an ID""" - interface Node { - """The id of the object.""" - id: ID! - } - - """Information about pagination in a connection.""" - type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: String - - """When paginating forwards, the cursor to continue.""" - endCursor: String - } - - """A connection to a list of items.""" - type PeopleConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PeopleEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - people: [Person] - } - - """An edge in a connection.""" - type PeopleEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """An individual person or character within the Star Wars universe.""" - type Person implements Node { - """The name of this person.""" - name: String - - """ - The birth year of the person, using the in-universe standard of BBY or ABY - - Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is - a battle that occurs at the end of Star Wars episode IV: A New Hope. - """ - birthYear: String - - """ - The eye color of this person. Will be "unknown" if not known or "n/a" if the - person does not have an eye. - """ - eyeColor: String - - """ - The gender of this person. Either "Male", "Female" or "unknown", - "n/a" if the person does not have a gender. - """ - gender: String - - """ - The hair color of this person. Will be "unknown" if not known or "n/a" if the - person does not have hair. - """ - hairColor: String - - """The height of the person in centimeters.""" - height: Int - - """The mass of the person in kilograms.""" - mass: Float - - """The skin color of this person.""" - skinColor: String - - """A planet that this person was born on or inhabits.""" - homeworld: Planet - filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection - - """The species that this person belongs to, or null if unknown.""" - species: Species - starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection - vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type PersonFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type PersonFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PersonStarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonStarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type PersonStarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PersonVehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonVehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type PersonVehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } - - """ - A large mass, planet or planetoid in the Star Wars Universe, at the time of - 0 ABY. - """ - type Planet implements Node { - """The name of this planet.""" - name: String - - """The diameter of this planet in kilometers.""" - diameter: Int - - """ - The number of standard hours it takes for this planet to complete a single - rotation on its axis. - """ - rotationPeriod: Int - - """ - The number of standard days it takes for this planet to complete a single orbit - of its local star. - """ - orbitalPeriod: Int - - """ - A number denoting the gravity of this planet, where "1" is normal or 1 standard - G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. - """ - gravity: String - - """The average population of sentient beings inhabiting this planet.""" - population: Float - - """The climates of this planet.""" - climates: [String] - - """The terrains of this planet.""" - terrains: [String] - - """ - The percentage of the planet surface that is naturally occuring water or bodies - of water. - """ - surfaceWater: Float - residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection - filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type PlanetFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type PlanetFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PlanetResidentsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetResidentsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - residents: [Person] - } - - """An edge in a connection.""" - type PlanetResidentsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PlanetsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - planets: [Planet] - } - - """An edge in a connection.""" - type PlanetsEdge { - """The item at the end of the edge""" - node: Planet - - """A cursor for use in pagination""" - cursor: String! - } - - type Root { - allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection - film(id: ID, filmID: ID): Film - allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection - person(id: ID, personID: ID): Person - allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection - planet(id: ID, planetID: ID): Planet - allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection - species(id: ID, speciesID: ID): Species - allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection - starship(id: ID, starshipID: ID): Starship - allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection - vehicle(id: ID, vehicleID: ID): Vehicle - - """Fetches an object given its ID""" - node( - """The ID of an object""" - id: ID! - ): Node - } - - """A type of person or character within the Star Wars Universe.""" - type Species implements Node { - """The name of this species.""" - name: String - - """The classification of this species, such as "mammal" or "reptile".""" - classification: String - - """The designation of this species, such as "sentient".""" - designation: String - - """The average height of this species in centimeters.""" - averageHeight: Float - - """The average lifespan of this species in years, null if unknown.""" - averageLifespan: Int - - """ - Common eye colors for this species, null if this species does not typically - have eyes. - """ - eyeColors: [String] - - """ - Common hair colors for this species, null if this species does not typically - have hair. - """ - hairColors: [String] - - """ - Common skin colors for this species, null if this species does not typically - have skin. - """ - skinColors: [String] - - """The language commonly spoken by this species.""" - language: String - - """A planet that this species originates from.""" - homeworld: Planet - personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection - filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type SpeciesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - species: [Species] - } - - """An edge in a connection.""" - type SpeciesEdge { - """The item at the end of the edge""" - node: Species - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type SpeciesFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type SpeciesFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type SpeciesPeopleConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesPeopleEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - people: [Person] - } - - """An edge in a connection.""" - type SpeciesPeopleEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A single transport craft that has hyperdrive capability.""" - type Starship implements Node { - """The name of this starship. The common name, such as "Death Star".""" - name: String - - """ - The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 - Orbital Battle Station". - """ - model: String - - """ - The class of this starship, such as "Starfighter" or "Deep Space Mobile - Battlestation" - """ - starshipClass: String - - """The manufacturers of this starship.""" - manufacturers: [String] - - """The cost of this starship new, in galactic credits.""" - costInCredits: Float - - """The length of this starship in meters.""" - length: Float - - """The number of personnel needed to run or pilot this starship.""" - crew: String - - """The number of non-essential people this starship can transport.""" - passengers: String - - """ - The maximum speed of this starship in atmosphere. null if this starship is - incapable of atmosphering flight. - """ - maxAtmospheringSpeed: Int - - """The class of this starships hyperdrive.""" - hyperdriveRating: Float - - """ - The Maximum number of Megalights this starship can travel in a standard hour. - A "Megalight" is a standard unit of distance and has never been defined before - within the Star Wars universe. This figure is only really useful for measuring - the difference in speed of starships. We can assume it is similar to AU, the - distance between our Sun (Sol) and Earth. - """ - MGLT: Int - - """The maximum number of kilograms that this starship can transport.""" - cargoCapacity: Float - - """ - The maximum length of time that this starship can provide consumables for its - entire crew without having to resupply. - """ - consumables: String - pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection - filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type StarshipFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type StarshipFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type StarshipPilotsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipPilotsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - pilots: [Person] - } - - """An edge in a connection.""" - type StarshipPilotsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type StarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type StarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A single transport craft that does not have hyperdrive capability""" - type Vehicle implements Node { - """ - The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder - bike". - """ - name: String - - """ - The model or official name of this vehicle. Such as "All-Terrain Attack - Transport". - """ - model: String - - """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" - vehicleClass: String - - """The manufacturers of this vehicle.""" - manufacturers: [String] - - """The cost of this vehicle new, in Galactic Credits.""" - costInCredits: Float - - """The length of this vehicle in meters.""" - length: Float - - """The number of personnel needed to run or pilot this vehicle.""" - crew: String - - """The number of non-essential people this vehicle can transport.""" - passengers: String - - """The maximum speed of this vehicle in atmosphere.""" - maxAtmospheringSpeed: Int - - """The maximum number of kilograms that this vehicle can transport.""" - cargoCapacity: Float - - """ - The maximum length of time that this vehicle can provide consumables for its - entire crew without having to resupply. - """ - consumables: String - pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection - filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type VehicleFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehicleFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type VehicleFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type VehiclePilotsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehiclePilotsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - pilots: [Person] - } - - """An edge in a connection.""" - type VehiclePilotsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type VehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type VehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } diff --git a/packages/catalog-model/examples-relative/components/artist-lookup-component.yaml b/packages/catalog-model/examples-relative/components/artist-lookup-component.yaml deleted file mode 100644 index 257344be3d..0000000000 --- a/packages/catalog-model/examples-relative/components/artist-lookup-component.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: artist-lookup - description: Artist Lookup - tags: - - java - - data -spec: - type: service - lifecycle: experimental - owner: team-a diff --git a/packages/catalog-model/examples-relative/components/petstore-component.yaml b/packages/catalog-model/examples-relative/components/petstore-component.yaml deleted file mode 100644 index e6dd56c274..0000000000 --- a/packages/catalog-model/examples-relative/components/petstore-component.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: petstore - description: Petstore -spec: - type: service - lifecycle: experimental - owner: team-c - implementsApis: - - petstore - - streetlights - - hello-world diff --git a/packages/catalog-model/examples-relative/components/playback-lib-component.yaml b/packages/catalog-model/examples-relative/components/playback-lib-component.yaml deleted file mode 100644 index f7d7670b5d..0000000000 --- a/packages/catalog-model/examples-relative/components/playback-lib-component.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: playback-sdk - description: Audio and video playback SDK -spec: - type: library - lifecycle: experimental - owner: team-c diff --git a/packages/catalog-model/examples-relative/components/playback-order-component.yaml b/packages/catalog-model/examples-relative/components/playback-order-component.yaml deleted file mode 100644 index c4f41b2b58..0000000000 --- a/packages/catalog-model/examples-relative/components/playback-order-component.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: playback-order - description: Playback Order - tags: - - java - - playback -spec: - type: service - lifecycle: production - owner: user:guest diff --git a/packages/catalog-model/examples-relative/components/podcast-api-component.yaml b/packages/catalog-model/examples-relative/components/podcast-api-component.yaml deleted file mode 100644 index b89ff48c48..0000000000 --- a/packages/catalog-model/examples-relative/components/podcast-api-component.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: podcast-api - description: Podcast API - tags: - - java -spec: - type: service - lifecycle: experimental - owner: team-b diff --git a/packages/catalog-model/examples-relative/components/queue-proxy-component.yaml b/packages/catalog-model/examples-relative/components/queue-proxy-component.yaml deleted file mode 100644 index 7f7fcbd527..0000000000 --- a/packages/catalog-model/examples-relative/components/queue-proxy-component.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: queue-proxy - description: Queue Proxy - tags: - - go - - website -spec: - type: website - lifecycle: production - owner: team-b diff --git a/packages/catalog-model/examples-relative/components/searcher-component.yaml b/packages/catalog-model/examples-relative/components/searcher-component.yaml deleted file mode 100644 index 77150c96f0..0000000000 --- a/packages/catalog-model/examples-relative/components/searcher-component.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: searcher - description: Searcher - tags: - - go -spec: - type: service - lifecycle: production - owner: user:guest diff --git a/packages/catalog-model/examples-relative/components/shuffle-api-component.yaml b/packages/catalog-model/examples-relative/components/shuffle-api-component.yaml deleted file mode 100644 index 1c2da03511..0000000000 --- a/packages/catalog-model/examples-relative/components/shuffle-api-component.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: shuffle-api - description: Shuffle API - tags: - - go -spec: - type: service - lifecycle: production - owner: user:guest diff --git a/packages/catalog-model/examples-relative/components/www-artist-component.yaml b/packages/catalog-model/examples-relative/components/www-artist-component.yaml deleted file mode 100644 index c333eb8c09..0000000000 --- a/packages/catalog-model/examples-relative/components/www-artist-component.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: www-artist - description: Artist main website -spec: - type: website - lifecycle: production - owner: team-a diff --git a/packages/catalog-model/examples/README.md b/packages/catalog-model/examples/README.md index abf4934f3f..77e1084899 100644 --- a/packages/catalog-model/examples/README.md +++ b/packages/catalog-model/examples/README.md @@ -1,7 +1,4 @@ # Example Entities -NOTE: These are being replaced with the contents of the `examples-relative` -sibling directory, after December 16 2020. If you are using this example data -you will need to be using a released version of the catalog backend which -supports relative targets in Location entities -[see #3513](https://github.com/backstage/backstage/pull/3513) after that time. +This is a set of example entities that you can make use of to demonstrate basic +Backstage features. diff --git a/packages/catalog-model/examples/acme-corp.yaml b/packages/catalog-model/examples/acme-corp.yaml index e8047fc143..6449d548e0 100644 --- a/packages/catalog-model/examples/acme-corp.yaml +++ b/packages/catalog-model/examples/acme-corp.yaml @@ -4,6 +4,5 @@ metadata: name: acme-corp description: A collection of all Backstage example Groups spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml + - ./acme/org.yaml diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml index f992d155bc..f83587532c 100644 --- a/packages/catalog-model/examples/acme/backstage-group.yaml +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -5,7 +5,9 @@ metadata: description: The backstage sub-department spec: type: sub-department + profile: + displayName: Backstage + email: backstage@example.com + picture: https://avatars.dicebear.com/api/identicon/backstage@example.com.svg?background=%23fff&margin=25 parent: infrastructure - ancestors: [infrastructure, acme-corp] children: [team-a, team-b] - descendants: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/boxoffice-group.yaml b/packages/catalog-model/examples/acme/boxoffice-group.yaml index 0be1fe58cb..fbd454c56e 100644 --- a/packages/catalog-model/examples/acme/boxoffice-group.yaml +++ b/packages/catalog-model/examples/acme/boxoffice-group.yaml @@ -5,7 +5,9 @@ metadata: description: The boxoffice sub-department spec: type: sub-department + profile: + displayName: Box Office + email: boxoffice@example.com + # Intentional no picture for testing parent: infrastructure - ancestors: [infrastructure, acme-corp] children: [team-c, team-d] - descendants: [team-c, team-d] diff --git a/packages/catalog-model/examples/acme/infrastructure-group.yaml b/packages/catalog-model/examples/acme/infrastructure-group.yaml index 2341782944..584578e6ce 100644 --- a/packages/catalog-model/examples/acme/infrastructure-group.yaml +++ b/packages/catalog-model/examples/acme/infrastructure-group.yaml @@ -5,7 +5,6 @@ metadata: description: The infra department spec: type: department + # Intentional no profile for testing parent: acme-corp - ancestors: [acme-corp] children: [backstage, boxoffice] - descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 8c562aaf89..9a0d690b57 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -3,12 +3,18 @@ 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 - ancestors: [] + profile: + displayName: ACME Corp + email: info@example.com + picture: https://avatars.dicebear.com/api/identicon/info@example.com.svg?background=%23fff&margin=25 children: [infrastructure] - descendants: - [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d] --- apiVersion: backstage.io/v1alpha1 kind: Location @@ -16,12 +22,11 @@ metadata: name: example-groups description: A collection of all Backstage example Groups spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/infrastructure-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/boxoffice-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/backstage-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-a-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-b-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-c-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-d-group.yaml + - ./infrastructure-group.yaml + - ./boxoffice-group.yaml + - ./backstage-group.yaml + - ./team-a-group.yaml + - ./team-b-group.yaml + - ./team-c-group.yaml + - ./team-d-group.yaml diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml index 8dbf0bb839..e343209d5f 100644 --- a/packages/catalog-model/examples/acme/team-a-group.yaml +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team A spec: type: team + profile: + # Intentional no displayName for testing + email: team-a@example.com + picture: https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25 parent: backstage - ancestors: [backstage, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -16,9 +18,9 @@ metadata: name: breanna.davison spec: profile: - displayName: Breanna Davison + # 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 @@ -29,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 @@ -40,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" @@ -54,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 00e9e41d80..20ab8721ea 100644 --- a/packages/catalog-model/examples/acme/team-b-group.yaml +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team B spec: type: team + profile: + displayName: Team B + email: team-b@example.com + picture: https://avatars.dicebear.com/api/identicon/team-b@example.com.svg?background=%23fff&margin=25 parent: backstage - ancestors: [backstage, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,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 @@ -29,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 @@ -40,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 @@ -51,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 @@ -62,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 0f97f69cb3..639b591d61 100644 --- a/packages/catalog-model/examples/acme/team-c-group.yaml +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team C spec: type: team + profile: + displayName: Team C + email: team-c@example.com + picture: https://avatars.dicebear.com/api/identicon/team-c@example.com.svg?background=%23fff&margin=25 parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,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 @@ -29,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 @@ -40,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 @@ -51,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 @@ -62,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 5a1a53a2e6..898ed8be6c 100644 --- a/packages/catalog-model/examples/acme/team-d-group.yaml +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team D spec: type: team + profile: + displayName: Team D + email: team-d@example.com + picture: https://avatars.dicebear.com/api/identicon/team-d@example.com.svg?background=%23fff&margin=25 parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,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 @@ -29,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-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 0278fc3078..e23f1b3656 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -4,10 +4,11 @@ metadata: name: example-apis description: A collection of all Backstage example APIs spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/petstore-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml + - ./apis/hello-world-api.yaml + - ./apis/petstore-api.yaml + - ./apis/spotify-api.yaml + - ./apis/streetlights-api.yaml + - ./apis/swapi-graphql.yaml + - ./apis/wayback-archive-api.yaml + - ./apis/wayback-search-api.yaml diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index 06c44b59d7..9471b810a1 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -4,14 +4,17 @@ metadata: name: example-components description: A collection of all Backstage example components spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/petstore-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml + - ./components/artist-lookup-component.yaml + - ./components/petstore-component.yaml + - ./components/playback-order-component.yaml + - ./components/podcast-api-component.yaml + - ./components/queue-proxy-component.yaml + - ./components/searcher-component.yaml + - ./components/playback-lib-component.yaml + - ./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/apis/wayback-archive-api.yaml b/packages/catalog-model/examples/apis/wayback-archive-api.yaml new file mode 100644 index 0000000000..dbed93dbe8 --- /dev/null +++ b/packages/catalog-model/examples/apis/wayback-archive-api.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: wayback-archive + description: Archive API for the wayback machine +spec: + type: openapi + lifecycle: production + owner: team-a + definition: + $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/wayback/1.0.0/openapi.yaml diff --git a/packages/catalog-model/examples/apis/wayback-search-api.yaml b/packages/catalog-model/examples/apis/wayback-search-api.yaml new file mode 100644 index 0000000000..b39b5df468 --- /dev/null +++ b/packages/catalog-model/examples/apis/wayback-search-api.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: wayback-search + description: Search API for the wayback machine +spec: + type: openapi + lifecycle: production + owner: team-a + definition: + $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/search/1.0.0/openapi.yaml diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index 257344be3d..edd9b8fcf9 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -6,7 +6,30 @@ metadata: tags: - java - data + links: + - url: https://example.com/user + title: Examples Users + icon: user + - url: https://example.com/group + title: Example Group + icon: group + - url: https://example.com/cloud + title: Link with Cloud Icon + icon: cloud + - url: https://example.com/dashboard + title: Dashboard + icon: dashboard + - url: https://example.com/help + title: Support + icon: help + - url: https://example.com/web + title: Website + icon: web + - url: https://example.com/alert + title: Alerts + icon: alert 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 e6dd56c274..878fabd551 100644 --- a/packages/catalog-model/examples/components/petstore-component.yaml +++ b/packages/catalog-model/examples/components/petstore-component.yaml @@ -2,12 +2,17 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: petstore - description: Petstore + # This is an extra long description + description: The Petstore is an example API used to show features of the OpenAPI spec. + links: + - url: https://github.com/swagger-api/swagger-petstore + title: GitHub Repo + icon: github spec: type: service lifecycle: experimental owner: team-c - implementsApis: + providesApis: - petstore - streetlights - hello-world 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-component.yaml b/packages/catalog-model/examples/components/wayback-archive-component.yaml new file mode 100644 index 0000000000..5d558db64a --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-component.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive + description: Archive of the wayback machine +spec: + type: service + lifecycle: production + owner: team-a + providesApis: + - wayback-archive 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/wayback-search-component.yaml b/packages/catalog-model/examples/components/wayback-search-component.yaml new file mode 100644 index 0000000000..0187e4441f --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-search-component.yaml @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-search + description: Search of the wayback machine +spec: + type: service + lifecycle: production + owner: team-a + providesApis: + - wayback-search + consumesApis: + - 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 25406c8eef..554eea0937 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.4.0", + "version": "0.7.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/catalog-model" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,16 +29,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", + "@backstage/config": "^0.1.3", "@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.0", + "@backstage/cli": "^0.6.2", "@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/constants.ts b/packages/catalog-model/src/entity/constants.ts index 42ea2ae8ba..c8f88e3b0c 100644 --- a/packages/catalog-model/src/entity/constants.ts +++ b/packages/catalog-model/src/entity/constants.ts @@ -27,3 +27,9 @@ export const ENTITY_META_GENERATED_FIELDS = [ 'etag', 'generation', ] as const; + +/** + * Annotations for linking to entity from catalog pages. + */ +export const VIEW_URL_ANNOTATION = 'backstage.io/view-url'; +export const EDIT_URL_ANNOTATION = 'backstage.io/edit-url'; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index d450119f0a..e267c607e1 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -17,15 +17,19 @@ export { ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, + VIEW_URL_ANNOTATION, + EDIT_URL_ANNOTATION, } 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 2a196a65d8..2c63cc4c49 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -46,14 +46,14 @@ export function generateEntityEtag(): string { * are added or existing annotations were changed (since they are effectively * merged when doing updates). * + * Note that this comparison does NOT take state, relations or similar into + * account. It only compares the actual input entity data, i.e. metadata and + * spec. + * * @param previous The old state of the entity * @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); @@ -63,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; @@ -72,9 +84,9 @@ 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; return !lodash.isEqual(e1, e2); } @@ -98,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); @@ -115,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 8eec63daac..9284a5d5b1 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -33,9 +33,10 @@ describe('ComponentV1alpha1Validator', () => { type: 'service', lifecycle: 'production', owner: 'me', - implementsApis: ['api-0'], + subcomponentOf: 'monolith', providesApis: ['api-0'], consumesApis: ['api-0'], + system: 'system', }, }; }); @@ -104,24 +105,19 @@ describe('ComponentV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/owner/); }); - it('accepts missing implementsApis', async () => { - delete (entity as any).spec.implementsApis; + it('accepts missing subcomponentOf', async () => { + delete (entity as any).spec.subcomponentOf; await expect(validator.check(entity)).resolves.toBe(true); }); - it('rejects empty implementsApis', async () => { - (entity as any).spec.implementsApis = ['']; - await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); + it('rejects wrong subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = 7; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); }); - it('rejects undefined implementsApis', async () => { - (entity as any).spec.implementsApis = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); - }); - - it('accepts no implementsApis', async () => { - (entity as any).spec.implementsApis = []; - await expect(validator.check(entity)).resolves.toBe(true); + it('rejects empty subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = ''; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); }); it('accepts missing providesApis', async () => { @@ -163,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 680674f16e..3006a4288c 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -14,28 +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), - implementsApis: yup.array(yup.string().required()).notRequired(), - 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; @@ -43,19 +31,16 @@ export interface ComponentEntityV1alpha1 extends Entity { type: string; lifecycle: string; owner: string; - /** - * @deprecated This field will disappear on Dec 14th, 2020. Please remove - * any consuming code. The new field providesApis provides the - * same functionality like before. - */ - implementsApis?: 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.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts index 8b7bef8ff7..b25795cd31 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -28,15 +28,17 @@ describe('GroupV1alpha1Validator', () => { kind: 'Group', metadata: { name: 'doe-squad', - title: 'Doe Squad', description: 'A squad for John and Jane', }, spec: { type: 'squad', + profile: { + displayName: 'Doe Squad', + email: 'doe@doe.org', + picture: 'https://doe.org/doe', + }, parent: 'group-a', - ancestors: ['group-a', 'global-synergies', 'acme-corp'], children: ['child-a', 'child-b'], - descendants: ['desc-a', 'desc-b'], }, }; }); @@ -75,6 +77,70 @@ describe('GroupV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/type/); }); + // profile + + it('accepts missing profile', async () => { + delete (entity as any).spec.profile; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong profile', async () => { + (entity as any).spec.profile = 7; + await expect(validator.check(entity)).rejects.toThrow(/profile/); + }); + + it('profile accepts missing displayName', async () => { + delete (entity as any).spec.profile.displayName; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong displayName', async () => { + (entity as any).spec.profile.displayName = 7; + await expect(validator.check(entity)).rejects.toThrow(/displayName/); + }); + + it('profile rejects empty displayName', async () => { + (entity as any).spec.profile.displayName = ''; + await expect(validator.check(entity)).rejects.toThrow(/displayName/); + }); + + it('profile accepts missing email', async () => { + delete (entity as any).spec.profile.email; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong email', async () => { + (entity as any).spec.profile.email = 7; + await expect(validator.check(entity)).rejects.toThrow(/email/); + }); + + it('profile rejects empty email', async () => { + (entity as any).spec.profile.email = ''; + await expect(validator.check(entity)).rejects.toThrow(/email/); + }); + + it('profile accepts missing picture', async () => { + delete (entity as any).spec.profile.picture; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong picture', async () => { + (entity as any).spec.profile.picture = 7; + await expect(validator.check(entity)).rejects.toThrow(/picture/); + }); + + it('profile rejects empty picture', async () => { + (entity as any).spec.profile.picture = ''; + await expect(validator.check(entity)).rejects.toThrow(/picture/); + }); + + it('profile accepts unknown additional fields', async () => { + (entity as any).spec.profile.foo = 'data'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + // parent + it('accepts missing parent', async () => { delete (entity as any).spec.parent; await expect(validator.check(entity)).resolves.toBe(true); @@ -85,25 +151,7 @@ describe('GroupV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/parent/); }); - it('rejects missing ancestors', async () => { - delete (entity as any).spec.ancestors; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('rejects empty ancestors', async () => { - (entity as any).spec.ancestors = ['']; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('rejects undefined ancestors', async () => { - (entity as any).spec.ancestors = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('accepts no ancestors', async () => { - (entity as any).spec.ancestors = []; - await expect(validator.check(entity)).resolves.toBe(true); - }); + // children it('rejects missing children', async () => { delete (entity as any).spec.children; @@ -124,24 +172,4 @@ describe('GroupV1alpha1Validator', () => { (entity as any).spec.children = []; await expect(validator.check(entity)).resolves.toBe(true); }); - - it('rejects missing descendants', async () => { - delete (entity as any).spec.descendants; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('rejects empty descendants', async () => { - (entity as any).spec.descendants = ['']; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('rejects undefined descendants', async () => { - (entity as any).spec.descendants = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('accepts no descendants', async () => { - (entity as any).spec.descendants = []; - await expect(validator.check(entity)).resolves.toBe(true); - }); }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 7be432ad1f..d039fdaba8 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -14,71 +14,34 @@ * 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), - 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() - ancestors: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'ancestors must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - children: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'children must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - descendants: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'descendants must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - }) - .required(), -}); - export interface GroupEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; spec: { type: string; + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; parent?: string; - /** - * @deprecated This field will disappear on Dec 6th, 2020. Please remove - * any consuming code. Producers can stop producing this field - * before that date, as long as the catalog backend uses the - * BuiltinKindsEntityProcessor which inserts the fields in the - * mean time. - */ - ancestors: string[]; children: string[]; - /** - * @deprecated This field will disappear on Dec 6th, 2020. Please remove - * any consuming code. Producers can stop producing this field - * before that date, as long as the catalog backend uses the - * BuiltinKindsEntityProcessor which inserts the fields in the - * mean time. - */ - descendants: string[]; }; } -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/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts new file mode 100644 index 0000000000..60f3de9e59 --- /dev/null +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + TemplateEntityV1beta2, + templateEntityV1beta2Validator as validator, +} from './TemplateEntityV1beta2'; + +describe('templateEntityV1beta2Validator', () => { + let entity: TemplateEntityV1beta2; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1beta2', + kind: 'Template', + metadata: { + name: 'test', + }, + spec: { + type: 'website', + parameters: { + 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', + }, + }, + }, + steps: [ + { + id: 'fetch', + name: 'Fetch', + action: 'fetch:plan', + parameters: { + url: './template', + }, + }, + ], + output: { + fetchUrl: '{{ steps.fetch.output.targetUrl }}', + }, + }, + }; + }); + + it('happy path: accepts valid data', async () => { + 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('accepts any other type', async () => { + (entity as any).spec.type = 'hallo'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts missing parameters', async () => { + delete (entity as any).spec.parameters; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts missing outputs', async () => { + delete (entity as any).spec.outputs; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing steps', async () => { + delete (entity as any).spec.steps; + await expect(validator.check(entity)).rejects.toThrow(/steps/); + }); + + it('accepts step with missing id', async () => { + delete (entity as any).spec.steps[0].id; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts step with missing name', async () => { + delete (entity as any).spec.steps[0].name; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects step with missing action', async () => { + delete (entity as any).spec.steps[0].action; + await expect(validator.check(entity)).rejects.toThrow(/action/); + }); +}); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts new file mode 100644 index 0000000000..c78e7f85d5 --- /dev/null +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity, EntityMeta } from '../entity/Entity'; +import schema from '../schema/kinds/Template.v1beta2.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'; +import { JsonObject } from '@backstage/config'; + +const API_VERSION = ['backstage.io/v1beta2'] as const; +const KIND = 'Template' as const; + +export interface TemplateEntityV1beta2 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + metadata: EntityMeta & { + title?: string; + }; + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + parameters?: JsonObject; + }>; + output?: { [name: string]: string }; + }; +} + +export const templateEntityV1beta2Validator = 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..4ae2db483a 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,23 @@ 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 { templateEntityV1beta2Validator } from './TemplateEntityV1beta2'; +export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2'; 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..ba875c3edf 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -15,3 +15,7 @@ */ export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; +export const ORIGIN_LOCATION_ANNOTATION = + 'backstage.io/managed-by-origin-location'; + +export const SOURCE_LOCATION_ANNOTATION = 'backstage.io/source-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index ce64b988a6..6cfb074613 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -20,4 +20,8 @@ export { locationSpecSchema, analyzeLocationSchema, } from './validation'; -export { LOCATION_ANNOTATION } from './annotation'; +export { + LOCATION_ANNOTATION, + ORIGIN_LOCATION_ANNOTATION, + SOURCE_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/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json new file mode 100644 index 0000000000..bd9999f8bd --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "TemplateV1beta1", + "description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.", + "examples": [ + { + "apiVersion": "backstage.io/v1beta1", + "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", + "type": "website", + "parameters": { + "required": ["name", "description"], + "properties": { + "name": { + "title": "Name", + "type": "string", + "description": "Unique name of the component" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Description of the component" + } + } + }, + "steps": [ + { + "id": "fetch", + "name": "Fetch", + "action": "fetch:plain", + "parameters": { + "url": "./template" + } + }, + { + "id": "publish", + "name": "Publish to GitHub", + "action": "publish:github", + "parameters": { + "repoUrl": "{{ parameters.repoUrl }}" + } + } + ], + "output": { + "catalogInfoUrl": "{{ steps.publish.output.catalogInfoUrl }}" + } + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1beta2"] + }, + "kind": { + "enum": ["Template"] + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The nice display name for the template.", + "examples": ["React SSR Template"], + "minLength": 1 + } + } + }, + "spec": { + "type": "object", + "required": ["type", "steps"], + "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 + }, + "parameters": { + "oneOf": [ + { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + }, + { + "type": "array", + "description": "A list of separate forms to collect parameters.", + "items": { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + } + } + ] + }, + "steps": { + "type": "array", + "description": "A list of steps to execute.", + "items": { + "type": "object", + "description": "A description of the step to execute.", + "required": ["action"], + "properties": { + "id": { + "type": "string", + "description": "The ID of the step, which can be used to refer to its outputs." + }, + "name": { + "type": "string", + "description": "The name of the step, which will be displayed in the UI during the scaffolding process." + }, + "action": { + "type": "string", + "description": "The name of the action to execute." + }, + "parameters": { + "type": "object", + "description": "A templated object describing the inputs to the action." + } + } + } + }, + "output": { + "type": "object", + "description": "A templated object describing the outputs of the scaffolding task.", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + ] +} 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 45214cd1e0..148af6fa8a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,147 @@ # @backstage/cli +## 0.6.2 + +### Patch Changes + +- e780e119c: Add missing `file-loader` dependency which could cause issues with loading images and other assets. +- 6266ddd11: The `yarn backstage-cli app:diff` has been broken since a couple of months. The command to perform updates `yarn backstage-cli versions:bump` prints change logs which seems to be a good replacement for this command. +- Updated dependencies [a1f5e6545] + - @backstage/config@0.1.3 + +## 0.6.1 + +### Patch Changes + +- 257a753ff: Updated transform of `.esm.js` files to be able to handle dynamic imports. +- 9337f509d: Tweak error message in lockfile parsing to include more information. +- 532bc0ec0: Upgrading to lerna@4.0.0. + +## 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 + +- d45efbc9b: Fix typo in .app.listen.port config schema + +## 0.4.3 + +### 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` +- 7d72f9b09: Fix for `app.listen.host` configuration not properly overriding listening host. + +## 0.4.2 + +### Patch Changes + +- c36a01b4c: Re-enable symlink resolution during bundling, and switch to using a resolve plugin for external linked packages. + +## 0.4.1 + +### Patch Changes + +- 06dbe707b: Update experimental backend bundle command to only output archives to `dist/` instead of a full workspace mirror in `dist-workspace/`. +- 011708102: Fixes a big in the bundling logic that caused `node_modules` inside local monorepo packages to be transformed. +- 61897fb2c: Fix config schema for `.app.listen` +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + ## 0.4.0 ### Minor Changes diff --git a/packages/cli/README.md b/packages/cli/README.md index 9eac5cdfe4..cd6ad8094e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,7 +4,7 @@ This package provides a CLI for developing Backstage plugins and apps. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/cli 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/config/jest.js b/packages/cli/config/jest.js index 61cd83b7b0..94cf059cbd 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -83,10 +83,8 @@ async function getConfig() { }, }, - // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed - // TODO: jest is working on module support, it's possible that we can remove this in the future transform: { - '\\.esm\\.js$': require.resolve('jest-esm-transformer'), + '\\.esm\\.js$': require.resolve('./jestEsmTransform.js'), // See jestEsmTransform.js '\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( './jestFileTransform.js', diff --git a/packages/cli/config/jestEsmTransform.js b/packages/cli/config/jestEsmTransform.js new file mode 100644 index 0000000000..99f1a600bc --- /dev/null +++ b/packages/cli/config/jestEsmTransform.js @@ -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. + */ + +const babel = require('@babel/core'); + +// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed +// TODO: jest is working on module support, it's possible that we can remove this in the future +module.exports = { + process(src) { + const result = babel.transform(src, { + babelrc: false, + compact: false, + plugins: [ + // This transforms the regular ESM syntax, import and export statements + require.resolve('@babel/plugin-transform-modules-commonjs'), + // This transforms dynamic `import()`, which is not supported yet in the Node.js VM API + require.resolve('babel-plugin-dynamic-import-node'), + ], + }); + + return result.code; + }, +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d585436bb..1018d05221 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.0", + "version": "0.6.2", "private": false, "publishConfig": { "access": "public" @@ -28,13 +28,16 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { + "@babel/core": "^7.4.4", + "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", - "@backstage/config-loader": "^0.4.0", + "@backstage/config": "^0.1.3", + "@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", + "@lerna/package-graph": "^4.0.0", + "@lerna/project": "^4.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", @@ -42,16 +45,17 @@ "@spotify/eslint-config-react": "^9.0.0", "@spotify/eslint-config-typescript": "^9.0.0", "@sucrase/webpack-loader": "^2.0.0", - "@svgr/plugin-jsx": "5.4.x", + "@svgr/plugin-jsx": "5.5.x", "@svgr/plugin-svgo": "5.4.x", - "@svgr/rollup": "5.4.x", - "@svgr/webpack": "5.4.x", + "@svgr/rollup": "5.5.x", + "@svgr/webpack": "5.5.x", "@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.15.2", + "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", + "babel-plugin-dynamic-import-node": "^2.3.3", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", @@ -66,9 +70,11 @@ "eslint-plugin-import": "^2.20.2", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsx-a11y": "^6.2.1", - "eslint-plugin-monorepo": "^0.2.1", + "eslint-plugin-monorepo": "^0.3.2", "eslint-plugin-react": "^7.12.4", "eslint-plugin-react-hooks": "^4.0.0", + "express": "^4.17.1", + "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", @@ -76,7 +82,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "jest-esm-transformer": "^1.0.0", + "json-schema": "^0.2.5", "lodash": "^4.17.19", "mini-css-extract-plugin": "^0.9.0", "ora": "^4.0.3", @@ -91,7 +97,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 +117,21 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/backend-common": "^0.5.4", + "@backstage/config": "^0.1.3", + "@backstage/core": "^0.6.3", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@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 +177,9 @@ "type": "string", "visibility": "frontend", "description": "Tracking ID for Google Analytics", - "example": "UA-000000-0" + "examples": [ + "UA-000000-0" + ] }, "listen": { "type": "object", @@ -182,7 +190,7 @@ "visibility": "frontend", "description": "The host that the frontend should be bound to. Only used for local development." }, - "post": { + "port": { "type": "number", "visibility": "frontend", "description": "The port that the frontend should be bound to. Only used for local development." diff --git a/packages/cli/src/commands/app/diff.ts b/packages/cli/src/commands/app/diff.ts deleted file mode 100644 index a667407f23..0000000000 --- a/packages/cli/src/commands/app/diff.ts +++ /dev/null @@ -1,74 +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 { Command } from 'commander'; -import { - diffTemplateFiles, - handlers, - handleAllFiles, - inquirerPromptFunc, - makeCheckPromptFunc, - yesPromptFunc, -} from '../../lib/diff'; -import { version } from '../../lib/version'; - -const fileHandlers = [ - { - patterns: ['packages/app/package.json'], - handler: handlers.appPackageJson, - }, - { - patterns: [/tsconfig\.json$/], - handler: handlers.exactMatch, - }, - { - patterns: [ - /README\.md$/, - /\.eslintrc\.js$/, - // make sure files in 1st level of src/ and dev/ exist - /^packages\/app\/(src|dev)\/[^/]+$/, - ], - handler: handlers.exists, - }, - { - patterns: [ - 'lerna.json', - /^src\//, - /^patches\//, - /^packages\/app\/public\//, - /^packages\/app\/cypress/, - // Let plugin:diff take care of the plugins - /^plugins/, - /package\.json$/, - ], - handler: handlers.skip, - }, -]; - -export default async (cmd: Command) => { - let promptFunc = inquirerPromptFunc; - let finalize = () => {}; - - if (cmd.check) { - [promptFunc, finalize] = makeCheckPromptFunc(); - } else if (cmd.yes) { - promptFunc = yesPromptFunc; - } - - const templateFiles = await diffTemplateFiles('default-app', { version }); - await handleAllFiles(fileHandlers, templateFiles, promptFunc); - await finalize(); -}; 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/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts index e604ae9733..25322045e1 100644 --- a/packages/cli/src/commands/backend/bundle.ts +++ b/packages/cli/src/commands/backend/bundle.ts @@ -14,26 +14,59 @@ * limitations under the License. */ -import { Command } from 'commander'; +import os from 'os'; import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import tar, { CreateOptions } from 'tar'; +import { Command } from 'commander'; import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { buildPackage, Output } from '../../lib/builder'; -const PKG_PATH = 'package.json'; -const TARGET_DIR = 'dist-workspace'; +const BUNDLE_FILE = 'bundle.tar.gz'; +const SKELETON_FILE = 'skeleton.tar.gz'; export default async (cmd: Command) => { - const targetDir = paths.resolveTarget(TARGET_DIR); - const pkgPath = paths.resolveTarget(PKG_PATH); - const pkg = await fs.readJson(pkgPath); + const targetDir = paths.resolveTarget('dist'); + const pkg = await fs.readJson(paths.resolveTarget('package.json')); - await fs.remove(targetDir); - await fs.mkdir(targetDir); - await createDistWorkspace([pkg.name], { - targetDir: targetDir, - buildDependencies: Boolean(cmd.build), - parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), - skeleton: 'skeleton.tar', - }); + // We build the target package without generating type declarations. + await buildPackage({ outputs: new Set([Output.cjs]) }); + + const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle')); + try { + await createDistWorkspace([pkg.name], { + targetDir: tmpDir, + buildDependencies: Boolean(cmd.buildDependencies), + buildExcludes: [pkg.name], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + skeleton: SKELETON_FILE, + }); + + // We built the target backend package using the regular build process, but the result of + // that has now been packed into the dist workspace, so clean up the dist dir. + await fs.remove(targetDir); + await fs.mkdir(targetDir); + + // Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice. + await fs.move( + resolvePath(tmpDir, SKELETON_FILE), + resolvePath(targetDir, SKELETON_FILE), + ); + + // Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache. + await tar.create( + { + file: resolvePath(targetDir, BUNDLE_FILE), + cwd: tmpDir, + portable: true, + noMtime: true, + gzip: true, + } as CreateOptions & { noMtime: boolean }, + [''], + ); + } finally { + await fs.remove(tmpDir); + } }; 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/schema.ts b/packages/cli/src/commands/config/schema.ts new file mode 100644 index 0000000000..63c1524789 --- /dev/null +++ b/packages/cli/src/commands/config/schema.ts @@ -0,0 +1,46 @@ +/* + * 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 { Command } from 'commander'; +import { JSONSchema7 as JSONSchema } from 'json-schema'; +import { stringify as stringifyYaml } from 'yaml'; +import { loadCliConfig } from '../../lib/config'; +import { JsonObject } from '@backstage/config'; +import { mergeConfigSchemas } from '@backstage/config-loader'; + +export default async (cmd: Command) => { + const { schema } = await loadCliConfig({ + args: [], + fromPackage: cmd.package, + mockEnv: true, + }); + + const merged = mergeConfigSchemas( + (schema.serialize().schemas as JsonObject[]).map( + _ => _.value as JSONSchema, + ), + ); + + merged.title = 'Application Configuration Schema'; + merged.description = + 'This is the schema describing the structure of the app-config.yaml configuration file.'; + + if (cmd.format === 'json') { + process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`); + } else { + process.stdout.write(`${stringifyYaml(merged)}\n`); + } +}; 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 f773f279fc..3a8ef36a97 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -45,9 +45,12 @@ export function registerCommands(program: CommanderStatic) { .action(lazy(() => import('./backend/build').then(m => m.default))); program - .command('backend:__experimental__bundle__', { hidden: true }) - .description('Bundle all backend packages into dist-workspace') - .option('--build', 'Build packages before packing them into the image') + .command('backend:bundle') + .description('Bundle the backend into a deployment archive') + .option( + '--build-dependencies', + 'Build all local package dependencies before bundling the backend', + ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); program @@ -73,13 +76,6 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); - program - .command('app:diff') - .option('--check', 'Fail if changes are required') - .option('--yes', 'Apply all changes') - .description('Diff an existing app with the creation template') - .action(lazy(() => import('./app/diff').then(m => m.default))); - program .command('create-plugin') .option( @@ -87,9 +83,9 @@ export function registerCommands(program: CommanderStatic) { 'Create plugin with the backend dependencies as default', ) .description('Creates a new plugin in the current repository') - .option('--scope ', 'NPM scope') - .option('--npm-registry ', 'NPM registry URL') - .option('--no-private', 'Public NPM Package') + .option('--scope ', 'npm scope') + .option('--npm-registry ', 'npm registry URL') + .option('--no-private', 'Public npm package') .action( lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); @@ -150,6 +146,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( @@ -166,12 +163,22 @@ 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', ) .action(lazy(() => import('./config/validate').then(m => m.default))); + program + .command('config:schema') + .option( + '--package ', + 'Only output config schema that applies to the given package', + ) + .description('Print configuration schema') + .action(lazy(() => import('./config/schema').then(m => m.default))); + program .command('versions:bump') .description('Bump Backstage packages to the latest versions') @@ -202,6 +209,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 @@ -212,6 +226,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/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 2e7847fdb1..a236e87806 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -62,7 +62,7 @@ export const makeConfigs = async ( output.push({ dir: 'dist', entryFileNames: 'index.cjs.js', - chunkFileNames: 'cjs/[name]-[hash].js', + chunkFileNames: 'cjs/[name]-[hash].cjs.js', format: 'commonjs', sourcemap: true, }); @@ -71,7 +71,7 @@ export const makeConfigs = async ( output.push({ dir: 'dist', entryFileNames: 'index.esm.js', - chunkFileNames: 'esm/[name]-[hash].js', + chunkFileNames: 'esm/[name]-[hash].esm.js', format: 'module', sourcemap: true, }); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts new file mode 100644 index 0000000000..f3906bfd0d --- /dev/null +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 * as path from 'path'; +import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; + +describe('LinkedPackageResolvePlugin', () => { + const root = os.platform() === 'win32' ? 'C:\\root' : '/root'; + + it('should re-write paths for external packages', () => { + const plugin = new LinkedPackageResolvePlugin( + path.resolve(root, 'repo/node_modules'), + [ + { + name: 'a', + location: path.resolve(root, 'external-a'), + }, + { + name: '@s/b', + location: path.resolve(root, 'external-b'), + }, + ], + ); + + const tapAsync = jest.fn(); + const doResolve = jest.fn(); + + const resolver = { + hooks: { resolve: { tapAsync } }, + doResolve, + }; + plugin.apply(resolver); + + expect(tapAsync).toHaveBeenCalledTimes(1); + expect(tapAsync).toHaveBeenCalledWith( + 'LinkedPackageResolvePlugin', + expect.any(Function), + ); + expect(doResolve).toHaveBeenCalledTimes(0); + + // Internal module resolution is not affected + const tap = tapAsync.mock.calls[0][1]; + const callbackX = jest.fn(); + tap( + { + request: path.resolve(root, 'repo/package/x/src/module.ts'), + path: path.resolve(root, 'repo/package/x/src'), + context: { + issuer: path.resolve(root, 'repo/package/x/src/index.ts'), + }, + }, + 'some-context', + callbackX, + ); + expect(callbackX).toHaveBeenCalledTimes(1); + expect(callbackX).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + + // Path is sometimes false + const callbackFalse = jest.fn(); + tap( + { + request: 'dummy', + path: false, + }, + 'some-context', + callbackFalse, + ); + expect(callbackFalse).toHaveBeenCalledTimes(1); + 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( + { + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'external-a/src'), + context: { + issuer: path.resolve(root, 'external-a/src/index.ts'), + }, + }, + 'some-context', + callbackA, + ); + expect(callbackA).toHaveBeenCalledTimes(0); + expect(doResolve).toHaveBeenCalledTimes(1); + expect(doResolve).toHaveBeenCalledWith( + resolver.hooks.resolve, + { + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/a/src'), + context: { + issuer: path.resolve(root, 'repo/node_modules/a/src/index.ts'), + }, + }, + `resolve ${path.resolve( + root, + 'external-a/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/a')}`, + 'some-context', + callbackA, + ); + + // Also handles scoped packages correctly, and issuer is not required + const callbackB = jest.fn(); + tap( + { + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'external-b/src'), + context: { + issuer: false, + }, + }, + 'some-context', + callbackB, + ); + expect(callbackB).toHaveBeenCalledTimes(0); + expect(doResolve).toHaveBeenCalledTimes(2); + expect(doResolve).toHaveBeenLastCalledWith( + resolver.hooks.resolve, + { + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/@s/b/src'), + context: { + issuer: false, + }, + }, + `resolve ${path.resolve( + root, + 'external-b/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/@s/b')}`, + 'some-context', + callbackB, + ); + + expect(tapAsync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts new file mode 100644 index 0000000000..fed8b39a19 --- /dev/null +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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. +// Without this the packages would depend on dependencies in the node_modules +// of the external packages themselves, leading to module duplication +export class LinkedPackageResolvePlugin implements ResolvePlugin { + constructor( + private readonly targetModules: string, + private readonly packages: LernaPackage[], + ) {} + + apply(resolver: any) { + resolver.hooks.resolve.tapAsync( + 'LinkedPackageResolvePlugin', + ( + data: { + request: string; + path?: false | string; + context?: { issuer?: string }; + }, + context: unknown, + callback: () => void, + ) => { + const pkg = this.packages.find( + pkge => data.path && isChildPath(pkge.location, data.path), + ); + if (!pkg) { + callback(); + return; + } + + // pkg here is an external package. We rewrite the context of any imports to resolve + // from the location of the package within the node_modules of the target root rather + // than the real location of the external package. + const modulesLocation = resolvePath(this.targetModules, pkg.name); + const newContext = data.context?.issuer + ? { + ...data.context, + issuer: data.context.issuer.replace( + pkg.location, + modulesLocation, + ), + } + : data.context; + + // Re-run resolution but this time from the point of view of our target monorepo rather + // than the location of the external package. By resolving modules using this method we avoid + // pulling in e.g. `react` from the external repo, which would otherwise lead to conflicts. + resolver.doResolve( + resolver.hooks.resolve, + { + ...data, + context: newContext, + path: data.path && data.path.replace(pkg.location, modulesLocation), + }, + `resolve ${data.request} in ${modulesLocation}`, + context, + callback, + ); + }, + ); + } +} diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index b5a27dd624..f81e055f69 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -24,9 +24,10 @@ 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 { BundlingOptions, BackendBundlingOptions } from './types'; +import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; +import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; import { version } from '../../lib/version'; import { paths as cliPaths } from '../../lib/paths'; import { runPlain } from '../run'; @@ -70,11 +71,9 @@ async function readBuildInfo() { }; } -async function loadLernaPackages(): Promise< - { name: string; location: string }[] -> { - const LernaProject = require('@lerna/project'); - const project = new LernaProject(cliPaths.targetDir); +async function loadLernaPackages(): Promise { + const { Project } = require('@lerna/project'); + const project = new Project(cliPaths.targetDir); return project.getPackages(); } @@ -85,12 +84,12 @@ export async function createConfig( const { checksEnabled, isDev, frontendConfig } = options; const packages = await loadLernaPackages(); - const { plugins, loaders } = transforms({ - ...options, - externalTransforms: packages.map(({ name }) => - cliPaths.resolveTargetRoot('node_modules', name), - ), - }); + 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 => !isChildPath(paths.root, p.location), + ); const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); @@ -165,6 +164,7 @@ export async function createConfig( extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], mainFields: ['browser', 'module', 'main'], plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], [paths.targetPackageJson], @@ -173,10 +173,6 @@ export async function createConfig( alias: { 'react-dom': '@hot-loader/react-dom', }, - // Enables proper resolution of packages when linking in external packages. - // Without this the packages would depend on dependencies in the node_modules - // of the external packages themselves, leading to module duplication - symlinks: false, }, module: { rules: loaders, @@ -205,13 +201,11 @@ export async function createBackendConfig( const moduleDirs = packages.map((p: any) => resolvePath(p.location, 'node_modules'), ); + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); // See frontend config - const { loaders } = transforms({ - ...options, - externalTransforms: packages.map(({ name }) => - cliPaths.resolveTargetRoot('node_modules', name), - ), - }); + const { loaders } = transforms(options); return { mode: isDev ? 'development' : 'production', @@ -225,7 +219,7 @@ export async function createBackendConfig( } : {}), externals: [ - nodeExternals({ + nodeExternalsWithResolve({ modulesDir: paths.rootNodeModules, additionalModuleDirs: moduleDirs, allowlist: ['webpack/hot/poll?100', ...localPackageNames], @@ -253,6 +247,7 @@ export async function createBackendConfig( mainFields: ['browser', 'module', 'main'], modules: [paths.rootNodeModules, ...moduleDirs], plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], [paths.targetPackageJson], @@ -261,7 +256,6 @@ export async function createBackendConfig( alias: { 'react-dom': '@hot-loader/react-dom', }, - symlinks: false, // See frontend config, added here for the same reason }, module: { rules: loaders, @@ -302,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/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index a5cdd1300c..5fc3a7b5de 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -58,10 +58,12 @@ export async function serveBundle(options: ServeOptions) { host, port, proxy: pkg.proxy, + // When the dev server is behind a proxy, the host and public hostname differ + allowedHosts: [url.hostname], }); await new Promise((resolve, reject) => { - server.listen(port, url.hostname, (err?: Error) => { + server.listen(port, host, (err?: Error) => { if (err) { reject(err); return; diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 6dc32e6563..4f097343c2 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -25,23 +25,17 @@ type Transforms = { type TransformOptions = { isDev: boolean; - // External paths that should be transformed - externalTransforms: string[]; }; export const transforms = (options: TransformOptions): Transforms => { - const { isDev, externalTransforms } = options; + const { isDev } = options; const extraTransforms = isDev ? ['react-hot-loader'] : []; - const transformExcludeCondition = { - and: [/node_modules/, { not: externalTransforms }], - }; - const loaders = [ { test: /\.(tsx?)$/, - exclude: transformExcludeCondition, + exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['typescript', 'jsx', ...extraTransforms], @@ -50,7 +44,7 @@ export const transforms = (options: TransformOptions): Transforms => { }, { test: /\.(jsx?|mjs)$/, - exclude: transformExcludeCondition, + exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['jsx', ...extraTransforms], diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 71343a1761..3bd941dd0f 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -53,3 +53,8 @@ export type BackendServeOptions = BundlingPathsOptions & { checksEnabled: boolean; inspectEnabled: boolean; }; + +export type LernaPackage = { + name: string; + location: string; +}; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 88aac1e33c..de6bd6353d 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -21,14 +21,15 @@ import { paths } from './paths'; type Options = { args: string[]; fromPackage?: string; + mockEnv?: boolean; }; export async function loadCliConfig(options: Options) { const configPaths = options.args.map(arg => paths.resolveTarget(arg)); // Consider all packages in the monorepo when loading in config - const LernaProject = require('@lerna/project'); - const project = new LernaProject(paths.targetDir); + const { Project } = require('@lerna/project'); + const project = new Project(paths.targetDir); const packages = await project.getPackages(); const localPackageNames = options.fromPackage @@ -40,13 +41,17 @@ 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, }); - console.log( - `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, + // printing to stderr to not clobber stdout in case the cli command + // outputs structured data (e.g. as config:schema does) + process.stderr.write( + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}\n`, ); try { @@ -72,7 +77,7 @@ export async function loadCliConfig(options: Options) { } function findPackages(packages: any[], fromPackage: string): string[] { - const PackageGraph = require('@lerna/package-graph'); + const { PackageGraph } = require('@lerna/package-graph'); const graph = new PackageGraph(packages); 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/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 8653e66d37..afe29fbd7c 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -67,6 +67,11 @@ type Options = { */ buildDependencies?: boolean; + /** + * When `buildDependencies` is set, this list of packages will not be built even if they are dependencies. + */ + buildExcludes?: string[]; + /** * Enable (true/false) or control amount of (number) parallelism in some build steps. */ @@ -76,7 +81,7 @@ type Options = { * If set, creates a skeleton tarball that contains all package.json files * with the same structure as the workspace dir. */ - skeleton?: 'skeleton.tar'; + skeleton?: 'skeleton.tar' | 'skeleton.tar.gz'; }; /** @@ -85,7 +90,7 @@ type Options = { * will be suitable for packaging e.g. into a docker image. * * This creates a structure that is functionally similar to if the packages where - * installed from NPM, but uses yarn workspaces to link to them at runtime. + * installed from npm, but uses Yarn workspaces to link to them at runtime. */ export async function createDistWorkspace( packageNames: string[], @@ -98,7 +103,11 @@ export async function createDistWorkspace( const targets = await findTargetPackages(packageNames); if (options.buildDependencies) { - const scopeArgs = targets.flatMap(target => ['--scope', target.name]); + const exclude = options.buildExcludes ?? []; + const scopeArgs = targets + .filter(target => !exclude.includes(target.name)) + .flatMap(target => ['--scope', target.name]); + const lernaArgs = options.parallel && Number.isInteger(options.parallel) ? ['--concurrency', options.parallel.toString()] @@ -131,6 +140,7 @@ export async function createDistWorkspace( cwd: targetDir, portable: true, noMtime: true, + gzip: options.skeleton.endsWith('.gz'), } as CreateOptions & { noMtime: boolean }, skeletonFiles, ); @@ -209,10 +219,10 @@ async function moveToDistWorkspace( } async function findTargetPackages(pkgNames: string[]): Promise { - const LernaProject = require('@lerna/project'); - const PackageGraph = require('@lerna/package-graph'); + const { Project } = require('@lerna/project'); + const { PackageGraph } = require('@lerna/package-graph'); - const project = new LernaProject(paths.targetDir); + const project = new Project(paths.targetDir); const packages = await project.getPackages(); const graph = new PackageGraph(packages); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index fd7c189b40..f0867c6408 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -37,7 +37,7 @@ type LockfileQueryEntry = { version: string; }; -/** Entries that have an invalid version range, for example an NPM tag */ +/** Entries that have an invalid version range, for example an npm tag */ type AnalyzeResultInvalidRange = { name: string; range: string; @@ -153,7 +153,7 @@ export class Lockfile { const acceptedVersion = versions.find(v => semver.satisfies(v, range)); if (!acceptedVersion) { throw new Error( - `No existing version was accepted for range ${range}, searching through ${versions}`, + `No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`, ); } diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index b0f1c46e8a..aca439a0e7 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', () => { @@ -50,8 +59,8 @@ describe('mapDependencies', () => { it('should read dependencies', async () => { // Make sure all modules involved in package discovery are in the module cache before we mock fs - const LernaProject = require('@lerna/project'); - const project = new LernaProject(paths.targetDir); + const { Project } = require('@lerna/project'); + const project = new Project(paths.targetDir); await project.getPackages(); mockFs({ diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 76e5dc49b0..777dc72757 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}`); @@ -61,8 +67,8 @@ export async function fetchPackageInfo( export async function mapDependencies( targetDir: string, ): Promise> { - const LernaProject = require('@lerna/project'); - const project = new LernaProject(targetDir); + const { Project } = require('@lerna/project'); + const project = new Project(targetDir); const packages = await project.getPackages(); const dependencyMap = new Map(); 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/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index a3f924408a..ffd197d163 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -38,7 +38,7 @@ "@backstage/dev-utils": "^{{version '@backstage/dev-utils'}}", "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", 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 e08f1650d5..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 @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { Typography, Grid } from '@material-ui/core'; import { InfoCard, @@ -9,9 +9,9 @@ import { HeaderLabel, SupportButton, } from '@backstage/core'; -import ExampleFetchComponent from '../ExampleFetchComponent'; +import { ExampleFetchComponent } from '../ExampleFetchComponent'; -const ExampleComponent: FC<{}> = () => ( +export const ExampleComponent = () => (
@@ -36,5 +36,3 @@ const ExampleComponent: FC<{}> = () => ( ); - -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 0af27a5935..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 @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { Table, TableColumn, Progress } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; @@ -38,7 +38,7 @@ type DenseTableProps = { users: User[]; }; -export const DenseTable: FC = ({ users }) => { +export const DenseTable = ({ users }: DenseTableProps) => { const classes = useStyles(); const columns: TableColumn[] = [ @@ -48,7 +48,7 @@ export const DenseTable: FC = ({ users }) => { { title: 'Nationality', field: 'nationality' }, ]; - const data = users.map((user) => { + const data = users.map(user => { return { avatar: ( = ({ users }) => { ); }; -const ExampleFetchComponent: FC<{}> = () => { +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: FC<{}> = () => { 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..07fd22d7ba 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,17 +32,17 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.1", - "ajv": "^6.12.5", + "@types/json-schema": "^7.0.6", + "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.49.0", "yaml": "^1.9.2", "yup": "^0.29.3" }, "devDependencies": { "@types/jest": "^26.0.7", - "@types/json-schema": "^7.0.6", "@types/json-schema-merge-allof": "^0.6.0", "@types/mock-fs": "^4.10.0", "@types/node": "^12.0.0", diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 9ad54c5f18..d9e5ae1350 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { readEnvConfig, loadConfigSchema } from './lib'; +export { readEnvConfig, loadConfigSchema, mergeConfigSchemas } from './lib'; export type { ConfigSchema, ConfigVisibility } from './lib'; export { loadConfig } from './loader'; export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 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..e85b6023cc 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,8 +69,49 @@ 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 = mergeConfigSchemas(schemas.map(_ => _.value)); + const validate = ajv.compile(merged); + + return configs => { + const config = ConfigReader.fromConfigs(configs).get(); + + visibilityByPath.clear(); + + const valid = validate(config); + if (!valid) { + const errors = validate.errors ?? []; + return { + errors: errors.map(({ dataPath, message, params }) => { + const paramStr = Object.entries(params) + .map(([name, value]) => `${name}=${value}`) + .join(' '); + return `Config ${message || ''} { ${paramStr} } at ${dataPath}`; + }), + visibilityByPath: new Map(), + }; + } + + return { + visibilityByPath: new Map(visibilityByPath), + }; + }; +} + +/** + * Given a list of configuration schemas from packages, merge them + * into a single json schema. + */ +export function mergeConfigSchemas(schemas: JSONSchema[]): JSONSchema { const merged = mergeAllOf( - { allOf: schemas.map(_ => _.value) }, + { allOf: schemas }, { // JSONSchema is typically subtractive, as in it always reduces the set of allowed // inputs through constraints. This changes the object property merging to be additive @@ -97,30 +140,5 @@ export function compileConfigSchemas( } as Partial>, }, ); - - const validate = ajv.compile(merged); - - return configs => { - const config = ConfigReader.fromConfigs(configs).get(); - - visibilityByPath.clear(); - - const valid = validate(config); - if (!valid) { - const errors = validate.errors ?? []; - return { - errors: errors.map(({ dataPath, message, params }) => { - const paramStr = Object.entries(params) - .map(([name, value]) => `${name}=${value}`) - .join(' '); - return `Config ${message || ''} { ${paramStr} } at ${dataPath}`; - }), - visibilityByPath: new Map(), - }; - } - - return { - visibilityByPath: new Map(visibilityByPath), - }; - }; + return merged; } 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/index.ts b/packages/config-loader/src/lib/schema/index.ts index 8cefb93b3c..00bb5c7d10 100644 --- a/packages/config-loader/src/lib/schema/index.ts +++ b/packages/config-loader/src/lib/schema/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export { mergeConfigSchemas } from './compile'; export { loadConfigSchema } from './load'; export type { ConfigSchema, ConfigVisibility } from './types'; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts 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 d80ada193b..0000000000 --- a/packages/config-loader/src/lib/secrets.test.ts +++ /dev/null @@ -1,126 +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 }', - } 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 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 97f0de2940..0000000000 --- a/packages/config-loader/src/lib/secrets.ts +++ /dev/null @@ -1,140 +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; -}; - -type Secret = FileSecret | EnvSecret | DataSecret; - -// Schema for each type of secret description -const secretLoaderSchemas = { - file: yup.object({ - file: yup.string().required(), - }), - env: yup.object({ - env: yup.string().required(), - }), - data: yup.object({ - data: yup.string().required(), - }), -}; - -// The top-level secret schema, which figures out what type of secret it is. -const secretSchema = yup.lazy(value => { - if (typeof value !== 'object' || value === null) { - return yup.object().required().label('secret'); - } - - const loaderTypes = Object.keys( - secretLoaderSchemas, - ) as (keyof typeof secretLoaderSchemas)[]; - - for (const key of loaderTypes) { - if (key in value) { - return secretLoaderSchemas[key]; - } - } - throw new yup.ValidationError( - `Secret must contain one of '${loaderTypes.join("', '")}'`, - value, - '$secret', - ); -}); - -// Parsers for each type of data secret file. -const dataSecretParser: { - [ext in string]: (content: string) => Promise; -} = { - '.json': async content => JSON.parse(content), - '.yaml': async content => yaml.parse(content), - '.yml': async content => yaml.parse(content), -}; - -/** - * Transforms a secret description into the actual secret value. - */ -export async function readSecret( - data: JsonObject, - ctx: ReaderContext, -): Promise { - const secret = secretSchema.validateSync(data, { strict: true }) as Secret; - - if ('file' in secret) { - return ctx.readFile(secret.file); - } - if ('env' in secret) { - return ctx.env[secret.env]; - } - if ('data' in secret) { - const 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); - } - - 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 e189aef20d..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 } 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 a647367469..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 } 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/config/CHANGELOG.md b/packages/config/CHANGELOG.md new file mode 100644 index 0000000000..cfc48fe21a --- /dev/null +++ b/packages/config/CHANGELOG.md @@ -0,0 +1,22 @@ +# @backstage/config + +## 0.1.3 + +### Patch Changes + +- a1f5e6545: Adds an optional type to `config.get` & `config.getOptional`. This avoids the need for casting. For example: + + ```ts + const config = useApi(configApiRef); + + const myConfig = config.get('myPlugin.complexConfig'); + // vs + const myConfig config.get('myPlugin.complexConfig') as SomeTypeDefinition; + ``` + +## 0.1.2 + +### Patch Changes + +- e3bd9fc2f: Fix unneeded defensive code +- e3bd9fc2f: Fix useless conditional diff --git a/packages/config/package.json b/packages/config/package.json index f1ae07ef64..1eadb5d4e5 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1", + "version": "0.1.3", "private": false, "publishConfig": { "access": "public", diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index eb8c91e366..99977d19cd 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -69,7 +69,7 @@ export class ConfigReader implements Config { constructor( private readonly data: JsonObject | undefined, - private readonly context: string = 'empty-config', + private readonly context: string = 'mock-config', private readonly fallback?: ConfigReader, private readonly prefix: string = '', ) {} @@ -88,22 +88,22 @@ export class ConfigReader implements Config { return [...new Set([...localKeys, ...fallbackKeys])]; } - get(key?: string): JsonValue { + get(key?: string): T { const value = this.getOptional(key); if (value === undefined) { throw new Error(errors.missing(this.fullKey(key ?? ''))); } - return value; + return value as T; } - getOptional(key?: string): JsonValue | undefined { + getOptional(key?: string): T | undefined { const value = this.readValue(key); - const fallbackValue = this.fallback?.getOptional(key); + const fallbackValue = this.fallback?.getOptional(key); if (value === undefined) { return fallbackValue; } else if (fallbackValue === undefined) { - return value; + return value as T; } // Avoid merging arrays and primitive values, since that's how merging works for other @@ -113,7 +113,7 @@ export class ConfigReader implements Config { { value: cloneDeep(fallbackValue) }, { value }, (into, from) => (!isObject(from) || !isObject(into) ? from : undefined), - ).value; + ).value as T; } getConfig(key: string): ConfigReader { @@ -272,23 +272,17 @@ export class ConfigReader implements Config { if (value === undefined) { return this.fallback?.readConfigValue(key, validate); } - if (value !== undefined) { - const result = validate(value); - if (result !== true) { - const { - key: keyName = key, - value: theValue = value, + const result = validate(value); + if (result !== true) { + const { key: keyName = key, value: theValue = value, expected } = result; + throw new TypeError( + errors.type( + this.fullKey(keyName), + this.context, + typeOf(theValue), expected, - } = result; - throw new TypeError( - errors.type( - this.fullKey(keyName), - this.context, - typeOf(theValue), - expected, - ), - ); - } + ), + ); } return value as T; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 840cc36173..92293a3f26 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -34,8 +34,8 @@ export type Config = { keys(): string[]; - get(key?: string): JsonValue; - getOptional(key?: string): JsonValue | undefined; + get(key?: string): T; + getOptional(key?: string): T | undefined; getConfig(key: string): Config; getOptionalConfig(key: string): Config | undefined; diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index ab333b998e..7fa7e71828 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,103 @@ # @backstage/core-api +## 0.2.11 + +### Patch Changes + +- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched. +- 1407b34c6: More informative error message for missing ApiContext. +- b6c4f485d: Fix error when querying Backstage Identity with SAML authentication +- 3a58084b6: Created separate `AppContext` type to be returned from `useApp` rather than the `BackstageApp` itself. The `AppContext` type includes but deprecates `getPlugins`, `getProvider`, `getRouter`, and `getRoutes`. In addition, the `AppContext` adds a new `getComponents` method which providers access to the app components. +- Updated dependencies [a1f5e6545] + - @backstage/config@0.1.3 + +## 0.2.10 + +### Patch Changes + +- f10950bd2: Minor refactoring of BackstageApp.getSystemIcons to support custom registered + icons. Custom Icons can be added using: + + ```tsx + import AlarmIcon from '@material-ui/icons/Alarm'; + import MyPersonIcon from './MyPerson'; + + const app = createApp({ + icons: { + user: MyPersonIcon // override system icon + alert: AlarmIcon, // Custom icon + }, + }); + ``` + +- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`. + +## 0.2.9 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + +## 0.2.8 + +### Patch Changes + +- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. +- 86c3c652a: Deprecate `RouteRef` path parameter and member, and remove deprecated `routeRef.createSubRouteRef`. +- 27f2af935: Delay auth loginPopup close to avoid race condition with callers of authFlowHelpers. + +## 0.2.7 + +### Patch Changes + +- d681db2b5: Fix for GitHub and SAML auth not properly updating session state when already logged in. +- 1dc445e89: Introduce new plugin extension API +- Updated dependencies [1dc445e89] + - @backstage/test-utils@0.1.6 + +## 0.2.6 + +### Patch Changes + +- 7dd2ef7d1: Use auth provider ID to create unique session storage keys for GitHub and SAML Auth. + +## 0.2.5 + +### Patch Changes + +- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + + This fixes for example the following code: + + ```ts + interface MyApi { + myMethod(): void + } + + const myApiRef = createApiRef({...}); + + createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), + }) + ``` + +- d8d5a17da: Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. + + Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. + + Add an as of yet unused `params` option to `createRouteRef`. + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1665ae8bb] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + ## 0.2.4 ### Patch Changes @@ -62,7 +160,7 @@ ![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) -- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the default scope is configurable when overwriting the Core Api in the app. ``` diff --git a/packages/core-api/package.json b/packages/core-api/package.json index bfff89f27a..2b01d209ec 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.4", + "version": "0.2.11", "private": false, "publishConfig": { "access": "public", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.3", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", + "@types/prop-types": "^15.7.3", "prop-types": "^15.7.2", "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", @@ -42,10 +42,11 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.2", + "@backstage/test-utils": "^0.1.8", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 2ce972af0e..459a361cf7 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -16,7 +16,10 @@ import { ApiRef, createApiRef } from '../system'; import { Config } from '@backstage/config'; -// Using interface to make the ConfigApi name show up in docs +/** + * The Config API is used to provide a mechanism to access the + * runtime configuration of the system. + */ export type ConfigApi = Config; export const configApiRef: ApiRef = createApiRef({ diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts index 9777835ec8..f62a97b61a 100644 --- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts @@ -31,7 +31,7 @@ export type DiscoveryApi = { /** * Returns the HTTP base backend URL for a given plugin, without a trailing slash. * - * This method must always be called just before making a request. as opposed to + * This method must always be called just before making a request, as opposed to * fetching the URL when constructing an API client. That is to ensure that more * flexible routing patterns can be supported. * diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index edd6966968..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 = { @@ -42,10 +42,10 @@ export type ErrorContext = { * to report errors for collection by error reporting services. * * If an error can be displayed inline, e.g. as feedback in a form, that should be - * preferred over relying on this API to display the error. The main use of this api + * preferred over relying on this API to display the error. The main use of this API * for displaying errors should be for asynchronous errors, such as a failing background process. * - * Even if an error is displayed inline, it should still be reported through this api + * Even if an error is displayed inline, it should still be reported through this API * if it would be useful to collect or log it for debugging purposes, but with * the hidden flag set. For example, an error arising from form field validation * should probably not be reported, while a failed REST call would be useful to report. diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 5cfc4723e0..2d29709b02 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -30,6 +30,7 @@ export type IdentityApi = { */ getUserId(): string; + // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. /** * The profile of the signed in user. */ @@ -43,8 +44,6 @@ export type IdentityApi = { */ getIdToken(): Promise; - // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. - /** * Sign out the current user */ diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 678dce9e32..e29d1022c4 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -25,9 +25,9 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; export * from './ConfigApi'; +export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; -export * from './DiscoveryApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index 532bff3480..a7c945a907 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -45,7 +45,7 @@ export type GithubAuthResponse = { const DEFAULT_PROVIDER = { id: 'github', - title: 'Github', + title: 'GitHub', icon: GithubIcon, }; @@ -84,7 +84,7 @@ class GithubAuth implements OAuthApi, SessionApi { const authSessionStore = new AuthSessionStore({ manager: sessionManager, - storageKey: 'githubSession', + storageKey: `${provider.id}Session`, sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index 8669dff022..3f4bc814e3 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -21,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'gitlab', - title: 'Gitlab', + title: 'GitLab', icon: GitlabIcon, }; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index da5f082c9b..af97ad8f36 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -63,7 +63,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { const authSessionStore = new AuthSessionStore({ manager: sessionManager, - storageKey: 'samlSession', + storageKey: `${provider.id}Session`, }); return new SamlAuth(authSessionStore); @@ -83,7 +83,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { } async getBackstageIdentity( - options: AuthRequestOptions, + options: AuthRequestOptions = {}, ): Promise { const session = await this.sessionManager.getSession(options); return session?.backstageIdentity; diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 556f63589c..9d9b058eac 100644 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -55,9 +55,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { * A factory will not be added to the registry if there is already * an existing factory with the same or higher priority. */ - register( + register( scope: ApiFactoryScope, - factory: ApiFactory, + factory: ApiFactory, ) { const priority = ScopePriority[scope]; const existing = this.factories.get(factory.api); @@ -69,12 +69,14 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { return true; } - get(api: ApiRef): ApiFactory | undefined { + get( + api: ApiRef, + ): ApiFactory | undefined { const tuple = this.factories.get(api); if (!tuple) { return undefined; } - return tuple.factory as ApiFactory; + return tuple.factory as ApiFactory; } getAllApis(): Set { diff --git a/packages/core-api/src/apis/system/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx index ac64aca3a7..71269697c2 100644 --- a/packages/core-api/src/apis/system/ApiProvider.test.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.test.tsx @@ -108,11 +108,11 @@ describe('ApiProvider', () => { withLogCollector(['error'], () => { expect(() => { render(); - }).toThrow('No ApiProvider available in react context'); + }).toThrow(/^No ApiProvider available in react context. /); }).error, ).toEqual([ expect.stringMatching( - /^Error: Uncaught \[Error: No ApiProvider available in react context\]/, + /^Error: Uncaught \[Error: No ApiProvider available in react context. /, ), expect.stringMatching( /^The above error occurred in the component/, @@ -123,11 +123,11 @@ describe('ApiProvider', () => { withLogCollector(['error'], () => { expect(() => { render(); - }).toThrow('No ApiProvider available in react context'); + }).toThrow(/^No ApiProvider available in react context. /); }).error, ).toEqual([ expect.stringMatching( - /^Error: Uncaught \[Error: No ApiProvider available in react context\]/, + /^Error: Uncaught \[Error: No ApiProvider available in react context. /, ), expect.stringMatching( /^The above error occurred in the component/, diff --git a/packages/core-api/src/apis/system/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx index f2aa70244e..d41730cb59 100644 --- a/packages/core-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.tsx @@ -14,11 +14,22 @@ * limitations under the License. */ -import React, { FC, createContext, useContext, ReactNode } from 'react'; +import React, { + createContext, + useContext, + ReactNode, + PropsWithChildren, +} from 'react'; import PropTypes from 'prop-types'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { ApiAggregator } from './ApiAggregator'; +const missingHolderMessage = + 'No ApiProvider available in react context. ' + + 'A common cause of this error is that multiple versions of @backstage/core-api are installed. ' + + `You can check if that is the case using 'yarn backstage-cli versions:check', and can in many cases ` + + `fix the issue either with the --fix flag or using 'yarn backstage-cli versions:bump'`; + type ApiProviderProps = { apis: ApiHolder; children: ReactNode; @@ -26,7 +37,10 @@ type ApiProviderProps = { const Context = createContext(undefined); -export const ApiProvider: FC = ({ apis, children }) => { +export const ApiProvider = ({ + apis, + children, +}: PropsWithChildren) => { const parentHolder = useContext(Context); const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; @@ -42,7 +56,7 @@ export function useApiHolder(): ApiHolder { const apiHolder = useContext(Context); if (!apiHolder) { - throw new Error('No ApiProvider available in react context'); + throw new Error(missingHolderMessage); } return apiHolder; @@ -62,11 +76,11 @@ export function withApis(apis: TypesToApiRefs) { return function withApisWrapper

( WrappedComponent: React.ComponentType

, ) { - const Hoc: FC> = props => { + const Hoc = (props: PropsWithChildren>) => { const apiHolder = useContext(Context); if (!apiHolder) { - throw new Error('No ApiProvider available in react context'); + throw new Error(missingHolderMessage); } const impls = {} as T; diff --git a/packages/core-api/src/apis/system/helpers.ts b/packages/core-api/src/apis/system/helpers.ts index 0ccd0cdb80..cabff73060 100644 --- a/packages/core-api/src/apis/system/helpers.ts +++ b/packages/core-api/src/apis/system/helpers.ts @@ -25,18 +25,19 @@ export function createApiFactory< Api, Impl extends Api, Deps extends { [name in string]: unknown } ->(factory: ApiFactory): ApiFactory; -export function createApiFactory( +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( api: ApiRef, - instance: Api, -): ApiFactory; + instance: Impl, +): ApiFactory; export function createApiFactory< Api, + Impl extends Api, Deps extends { [name in string]: unknown } >( - factory: ApiFactory | ApiRef, - instance?: Api, -): ApiFactory { + factory: ApiFactory | ApiRef, + instance?: Impl, +): ApiFactory { if ('id' in factory) { return { api: factory, diff --git a/packages/core-api/src/apis/system/types.ts b/packages/core-api/src/apis/system/types.ts index b0c19e551c..6234a55c71 100644 --- a/packages/core-api/src/apis/system/types.ts +++ b/packages/core-api/src/apis/system/types.ts @@ -34,16 +34,24 @@ export type ApiHolder = { get(api: ApiRef): T | undefined; }; -export type ApiFactory = { +export type ApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +> = { api: ApiRef; deps: TypesToApiRefs; - factory(deps: Deps): Api; + factory(deps: Deps): Impl; }; -export type AnyApiFactory = ApiFactory; +export type AnyApiFactory = ApiFactory< + unknown, + unknown, + { [key in string]: unknown } +>; export type ApiFactoryHolder = { get( api: ApiRef, - ): ApiFactory | undefined; + ): ApiFactory | undefined; }; diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx new file mode 100644 index 0000000000..5ceb123b06 --- /dev/null +++ b/packages/core-api/src/app/App.test.tsx @@ -0,0 +1,207 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { render, screen } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; +import { BrowserRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { defaultSystemIcons } from '../icons'; +import { createPlugin } from '../plugin'; +import { useRouteRef } from '../routing/hooks'; +import { createExternalRouteRef, createRouteRef } from '../routing/RouteRef'; +import { generateBoundRoutes, PrivateAppImpl } from './App'; + +describe('generateBoundRoutes', () => { + it('runs happy path', () => { + const external = { myRoute: createExternalRouteRef({ id: '1' }) }; + const ref = createRouteRef({ path: '', title: '' }); + const result = generateBoundRoutes(({ bind }) => { + bind(external, { myRoute: ref }); + }); + + expect(result.get(external.myRoute)).toBe(ref); + }); + + it('throws on unknown keys', () => { + const external = { myRoute: createExternalRouteRef({ id: '2' }) }; + const ref = createRouteRef({ path: '', title: '' }); + expect(() => + generateBoundRoutes(({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }), + ).toThrow('Key someOtherRoute is not an existing external route'); + }); +}); + +describe('Integration Test', () => { + const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' }); + const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' }); + const externalRouteRef = createExternalRouteRef({ id: '3' }); + const optionalBarExternalRouteRef = createExternalRouteRef({ + id: 'bar', + optional: true, + }); + const optionalBazExternalRouteRef = createExternalRouteRef({ + id: 'baz', + optional: true, + }); + + const plugin1 = createPlugin({ + id: 'blob', + externalRoutes: { + foo: externalRouteRef, + bar: optionalBarExternalRouteRef, + baz: optionalBazExternalRouteRef, + }, + }); + + const plugin2 = createPlugin({ + id: 'plugin2', + }); + + const HiddenComponent = plugin2.provide( + createRoutableExtension({ + component: () => Promise.resolve((_: { path?: string }) =>

), + mountPoint: plugin2RouteRef, + }), + ); + + const ExposedComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const externalLink = useRouteRef(externalRouteRef); + const barLink = useRouteRef(optionalBarExternalRouteRef); + const bazLink = useRouteRef(optionalBazExternalRouteRef); + return ( +
+ Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'}, + baz: {bazLink?.() ?? 'none'} +
+ ); + }), + mountPoint: plugin1RouteRef, + }), + ); + + it('runs happy paths', async () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + foo: plugin2RouteRef, + bar: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect( + screen.getByText('Our routes are: /foo/bar, bar: /foo/bar, baz: none'), + ).toBeInTheDocument(); + }); + + it('should throw some error when the route has duplicate params', () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { foo: plugin2RouteRef }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + const { error: errorLogs } = withLogCollector(() => { + expect(() => + render( + + + + + + + + + , + ), + ).toThrow( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ); + }); + expect(errorLogs).toEqual([ + expect.stringContaining( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ), + expect.stringContaining( + 'The above error occurred in the component', + ), + ]); + }); +}); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 3d32259337..9ab19b5588 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,57 +13,102 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { ComponentType, - FC, + PropsWithChildren, + ReactElement, useMemo, useState, - ReactElement, } from 'react'; -import { Route, Routes, Navigate } from 'react-router-dom'; -import { AppContextProvider } from './AppContext'; -import { - BackstageApp, - AppComponents, - AppConfigLoader, - SignInResult, - SignInPageProps, -} from './types'; -import { BackstagePlugin } from '../plugin'; -import { - featureFlagsApiRef, - AppThemeApi, - ConfigApi, - identityApiRef, -} from '../apis/definitions'; -import { AppThemeProvider } from './AppThemeProvider'; - -import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAsync } from 'react-use'; import { + AnyApiFactory, + ApiHolder, ApiProvider, ApiRegistry, AppTheme, - AppThemeSelector, appThemeApiRef, + AppThemeSelector, configApiRef, ConfigReader, - useApi, - AnyApiFactory, - ApiHolder, LocalStorageFeatureFlags, + useApi, } from '../apis'; -import { useAsync } from 'react-use'; +import { + AppThemeApi, + ConfigApi, + featureFlagsApiRef, + identityApiRef, +} from '../apis/definitions'; +import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { BackstagePlugin } from '../plugin'; +import { RouteRef } from '../routing'; +import { + routeObjectCollector, + routeParentCollector, + routePathCollector, +} from '../routing/collectors'; +import { RoutingProvider, validateRoutes } from '../routing/hooks'; +import { ExternalRouteRef } from '../routing/RouteRef'; +import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; -import { ApiResolver, ApiFactoryRegistry } from '../apis/system'; +import { AppThemeProvider } from './AppThemeProvider'; +import { + AppComponents, + AppConfigLoader, + AppContext, + AppOptions, + AppRouteBinder, + BackstageApp, + SignInPageProps, + SignInResult, +} from './types'; + +export function generateBoundRoutes( + bindRoutes: AppOptions['bindRoutes'], +): Map { + const result = new Map(); + + if (bindRoutes) { + const bind: AppRouteBinder = (externalRoutes, targetRoutes) => { + for (const [key, value] of Object.entries(targetRoutes)) { + const externalRoute = externalRoutes[key]; + if (!externalRoute) { + throw new Error(`Key ${key} is not an existing external route`); + } + if (!value && !externalRoute.optional) { + throw new Error( + `External route ${key} is required but was undefined`, + ); + } + if (value) { + result.set(externalRoute, value); + } + } + }; + bindRoutes({ bind }); + } + + return result; +} type FullAppOptions = { apis: Iterable; - icons: SystemIcons; - plugins: BackstagePlugin[]; + icons: IconComponentMap; + plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; configLoader?: AppConfigLoader; defaultApis: Iterable; + bindRoutes?: AppOptions['bindRoutes']; }; function useConfigLoader( @@ -101,17 +146,54 @@ function useConfigLoader( return { api: configReader }; } +class AppContextImpl implements AppContext { + constructor(private readonly app: PrivateAppImpl) {} + + getPlugins(): BackstagePlugin[] { + // eslint-disable-next-line no-console + console.warn('appContext.getPlugins() is deprecated and will be removed'); + return this.app.getPlugins(); + } + + getSystemIcon(key: IconKey): IconComponent | undefined { + return this.app.getSystemIcon(key); + } + + getComponents(): AppComponents { + return this.app.getComponents(); + } + + getProvider(): React.ComponentType<{}> { + // eslint-disable-next-line no-console + console.warn('appContext.getProvider() is deprecated and will be removed'); + return this.app.getProvider(); + } + + getRouter(): React.ComponentType<{}> { + // eslint-disable-next-line no-console + console.warn('appContext.getRouter() is deprecated and will be removed'); + return this.app.getRouter(); + } + + getRoutes(): JSX.Element[] { + // eslint-disable-next-line no-console + console.warn('appContext.getRoutes() is deprecated and will be removed'); + return this.app.getRoutes(); + } +} + export class PrivateAppImpl implements BackstageApp { private apiHolder?: ApiHolder; private configApi?: ConfigApi; private readonly apis: Iterable; - private readonly icons: SystemIcons; - private readonly plugins: BackstagePlugin[]; + private readonly icons: IconComponentMap; + private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; private readonly defaultApis: Iterable; + private readonly bindRoutes: AppOptions['bindRoutes']; private readonly identityApi = new AppIdentity(); @@ -123,21 +205,24 @@ export class PrivateAppImpl implements BackstageApp { this.themes = options.themes; this.configLoader = options.configLoader; this.defaultApis = options.defaultApis; + this.bindRoutes = options.bindRoutes; } - getPlugins(): BackstagePlugin[] { + getPlugins(): BackstagePlugin[] { return this.plugins; } - getSystemIcon(key: SystemIconKey): IconComponent { + getSystemIcon(key: IconKey): IconComponent | undefined { return this.icons[key]; } + getComponents(): AppComponents { + return this.components; + } + getRoutes(): JSX.Element[] { const routes = new Array(); - const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; - const { NotFoundErrorPage } = this.components; for (const plugin of this.plugins.values()) { @@ -171,13 +256,6 @@ export class PrivateAppImpl implements BackstageApp { routes.push(); break; } - case 'feature-flag': { - featureFlagsApi.registerFlag({ - name: output.name, - pluginId: plugin.getId(), - }); - break; - } default: break; } @@ -196,12 +274,49 @@ export class PrivateAppImpl implements BackstageApp { } getProvider(): ComponentType<{}> { - const Provider: FC<{}> = ({ children }) => { + const appContext = new AppContextImpl(this); + const apiHolder = this.getApiHolder(); + + const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; + + for (const plugin of this.plugins.values()) { + for (const output of plugin.output()) { + switch (output.type) { + case 'feature-flag': { + featureFlagsApi.registerFlag({ + name: output.name, + pluginId: plugin.getId(), + }); + break; + } + default: + break; + } + } + } + + const Provider = ({ children }: PropsWithChildren<{}>) => { const appThemeApi = useMemo( () => AppThemeSelector.createWithStorage(this.themes), [], ); + const { routePaths, routeParents, routeObjects } = useMemo(() => { + const result = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + validateRoutes(result.routePaths, result.routeParents); + + return result; + }, [children]); + const loadedConfig = useConfigLoader( this.configLoader, this.components, @@ -216,9 +331,18 @@ export class PrivateAppImpl implements BackstageApp { this.configApi = loadedConfig.api; return ( - - - {children} + + + + + {children} + + ); @@ -233,10 +357,13 @@ export class PrivateAppImpl implements BackstageApp { } = this.components; // This wraps the sign-in page and waits for sign-in to be completed before rendering the app - const SignInPageWrapper: FC<{ + const SignInPageWrapper = ({ + component: Component, + children, + }: { component: ComponentType; children: ReactElement; - }> = ({ component: Component, children }) => { + }) => { const [result, setResult] = useState(); if (result) { @@ -247,7 +374,7 @@ export class PrivateAppImpl implements BackstageApp { return ; }; - const AppRouter: FC<{}> = ({ children }) => { + const AppRouter = ({ children }: PropsWithChildren<{}>) => { const configApi = useApi(configApiRef); let { pathname } = new URL( diff --git a/packages/core-api/src/app/AppContext.tsx b/packages/core-api/src/app/AppContext.tsx index ec3831992f..c7c405a959 100644 --- a/packages/core-api/src/app/AppContext.tsx +++ b/packages/core-api/src/app/AppContext.tsx @@ -14,23 +14,26 @@ * limitations under the License. */ -import React, { createContext, useContext, FC } from 'react'; -import { BackstageApp } from './types'; +import React, { createContext, PropsWithChildren, useContext } from 'react'; +import { AppContext } from './types'; -const Context = createContext(undefined); +const Context = createContext(undefined); type Props = { - app: BackstageApp; + appContext: AppContext; }; -export const AppContextProvider: FC = ({ app, children }) => ( - +export const AppContextProvider = ({ + appContext, + children, +}: PropsWithChildren) => ( + ); -export const useApp = (): BackstageApp => { - const app = useContext(Context); - if (!app) { +export const useApp = (): AppContext => { + const appContext = useContext(Context); + if (!appContext) { throw new Error('No app context available'); } - return app; + return appContext; }; diff --git a/packages/core-api/src/app/AppThemeProvider.tsx b/packages/core-api/src/app/AppThemeProvider.tsx index 6bbcaea93a..993de23a7a 100644 --- a/packages/core-api/src/app/AppThemeProvider.tsx +++ b/packages/core-api/src/app/AppThemeProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useMemo, useEffect, useState } from 'react'; +import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; import { ThemeProvider, CssBaseline } from '@material-ui/core'; import { useApi, appThemeApiRef, AppTheme } from '../apis'; import { useObservable } from 'react-use'; @@ -68,7 +68,7 @@ const useShouldPreferDarkTheme = () => { return shouldPreferDark; }; -export const AppThemeProvider: FC<{}> = ({ children }) => { +export function AppThemeProvider({ children }: PropsWithChildren<{}>) { const appThemeApi = useApi(appThemeApiRef); const themeId = useObservable( appThemeApi.activeThemeId$(), @@ -94,4 +94,4 @@ export const AppThemeProvider: FC<{}> = ({ children }) => { {children} ); -}; +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 8a1eb92e8e..701862601d 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,8 +15,9 @@ */ import { ComponentType } from 'react'; -import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; -import { BackstagePlugin } from '../plugin'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { BackstagePlugin } from '../plugin/types'; +import { ExternalRouteRef, RouteRef } from '../routing'; import { AnyApiFactory } from '../apis'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; @@ -78,6 +79,51 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; +/** + * Extracts the Optional type of a map of ExternalRouteRefs, leaving only the boolean in place as value + */ +type ExternalRouteRefsToOptionalMap< + T extends { [name in string]: ExternalRouteRef } +> = { + [name in keyof T]: T[name] extends ExternalRouteRef ? U : never; +}; + +/** + * Extracts a union of the keys in a map whose value extends the given type + */ +type ExtractKeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Given a map of boolean values denoting whether a route is optional, create a + * map of needed RouteRefs. + * + * For example { foo: false, bar: true } gives { foo: RouteRef, bar?: RouteRef } + */ +type CombineOptionalAndRequiredRoutes< + OptionalMap extends { [key in string]: boolean } +> = { + [name in ExtractKeysWithType]: RouteRef; +} & + { [name in keyof OptionalMap]?: RouteRef }; + +/** + * Creates a map of required target routes based on whether the input external + * routes are optional or not. The external routes that are marked as optional + * will also be optional in the target routes map. + */ +type TargetRoutesMap< + T extends { [name in string]: ExternalRouteRef } +> = CombineOptionalAndRequiredRoutes>; + +export type AppRouteBinder = < + ExternalRoutes extends { [name in string]: ExternalRouteRef } +>( + externalRoutes: ExternalRoutes, + targetRoutes: TargetRoutesMap, +) => void; + export type AppOptions = { /** * A collection of ApiFactories to register in the application to either @@ -88,12 +134,12 @@ export type AppOptions = { /** * Supply icons to override the default ones. */ - icons?: Partial; + icons?: IconComponentMap; /** * A list of all plugins to include in the app. */ - plugins?: BackstagePlugin[]; + plugins?: BackstagePlugin[]; /** * Supply components to the app to override the default ones. @@ -134,18 +180,38 @@ export type AppOptions = { * that was packaged by the backstage-cli and default docker container boot script. */ configLoader?: AppConfigLoader; + + /** + * A function that is used to register associations between cross-plugin route + * references, enabling plugins to navigate between each other. + * + * The `bind` function that is passed in should be used to bind all external + * routes of all used plugins. + * + * ```ts + * bindRoutes({ bind }) { + * bind(docsPlugin.externalRoutes, { + * homePage: managePlugin.routes.managePage, + * }) + * bind(homePagePlugin.externalRoutes, { + * settingsPage: settingsPlugin.routes.settingsPage, + * }) + * } + * ``` + */ + bindRoutes?(context: { bind: AppRouteBinder }): void; }; export type BackstageApp = { /** * Returns all plugins registered for the app. */ - getPlugins(): BackstagePlugin[]; + getPlugins(): BackstagePlugin[]; /** - * Get a common icon for this app. + * Get a common or custom icon for this app. */ - getSystemIcon(key: SystemIconKey): IconComponent; + getSystemIcon(key: IconKey): IconComponent | undefined; /** * Provider component that should wrap the Router created with getRouter() @@ -161,6 +227,40 @@ export type BackstageApp = { /** * Routes component that contains all routes for plugin pages in the app. + * + * @deprecated Registering routes in plugins is deprecated and this method will be removed. + */ + getRoutes(): JSX.Element[]; +}; + +export type AppContext = { + /** + * @deprecated Will be removed + */ + getPlugins(): BackstagePlugin[]; + + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: IconKey): IconComponent | undefined; + + /** + * Get the components registered for various purposes in the app. + */ + getComponents(): AppComponents; + + /** + * @deprecated Will be removed + */ + getProvider(): ComponentType<{}>; + + /** + * @deprecated Will be removed + */ + getRouter(): ComponentType<{}>; + + /** + * @deprecated Will be removed */ getRoutes(): JSX.Element[]; }; diff --git a/packages/core-api/src/extensions/componentData.test.tsx b/packages/core-api/src/extensions/componentData.test.tsx index 6c4abda40f..cb0918b290 100644 --- a/packages/core-api/src/extensions/componentData.test.tsx +++ b/packages/core-api/src/extensions/componentData.test.tsx @@ -59,4 +59,63 @@ describe('elementData', () => { 'Attempted to attach duplicate data "my-data" to component "MyComponent"', ); }); + + describe('works across versions', () => { + function getDataSymbol() { + const Component = () => null; + attachComponentData(Component, 'my-data', {}); + const [symbol] = Object.getOwnPropertySymbols(Component); + return symbol; + } + + it('should should be able to get data from older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect((element as any).type[symbol].map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + (Component as any)[symbol] = { + map: new Map([['my-data', data]]), + }; + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should be able to get data from newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + const container = (global as any)[ + '__@backstage/component-data-store__' + ].store.get(element.type); + expect(container.map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + (global as any)['__@backstage/component-data-store__'].store.set( + Component, + { + map: new Map([['my-data', data]]), + }, + ); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + }); }); diff --git a/packages/core-api/src/extensions/componentData.tsx b/packages/core-api/src/extensions/componentData.tsx index 1f88356a48..d18379d25f 100644 --- a/packages/core-api/src/extensions/componentData.tsx +++ b/packages/core-api/src/extensions/componentData.tsx @@ -15,21 +15,28 @@ */ import { ComponentType, ReactNode } from 'react'; +import { getGlobalSingleton } from '../lib/globalObject'; +// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x const DATA_KEY = Symbol('backstage-component-data'); -type DataContainer = { - map: Map; -}; - type ComponentWithData

= ComponentType

& { [DATA_KEY]?: DataContainer; }; -type ReactNodeWithData = ReactNode & { - type?: { [DATA_KEY]?: DataContainer }; +type DataContainer = { + map: Map; }; +type MaybeComponentNode = ReactNode & { + type?: ComponentType & { [DATA_KEY]?: DataContainer }; +}; + +// The store is bridged across versions using the global object +const { store } = getGlobalSingleton('component-data-store', () => ({ + store: new WeakMap, DataContainer>(), +})); + export function attachComponentData

( component: ComponentType

, type: string, @@ -37,9 +44,11 @@ export function attachComponentData

( ) { const dataComponent = component as ComponentWithData

; - let container = dataComponent[DATA_KEY]; + let container = store.get(component) || dataComponent[DATA_KEY]; if (!container) { - container = dataComponent[DATA_KEY] = { map: new Map() }; + container = { map: new Map() }; + store.set(component, container); + dataComponent[DATA_KEY] = container; } if (container.map.has(type)) { @@ -60,7 +69,12 @@ export function getComponentData( return undefined; } - const container = (node as ReactNodeWithData).type?.[DATA_KEY]; + const component = (node as MaybeComponentNode).type; + if (!component) { + return undefined; + } + + const container = store.get(component) || component[DATA_KEY]; if (!container) { return undefined; } diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx index 92fcecebc4..26755b3bcf 100644 --- a/packages/core-api/src/extensions/extensions.test.tsx +++ b/packages/core-api/src/extensions/extensions.test.tsx @@ -30,10 +30,12 @@ const plugin = createPlugin({ describe('extensions', () => { it('should create a react extension with component data', () => { - const Component = () => null; + const Component = () =>

; const extension = createReactExtension({ - component: Component, + component: { + sync: Component, + }, data: { myData: { foo: 'bar' }, }, @@ -47,15 +49,17 @@ describe('extensions', () => { }); it('should create react extensions of different types', () => { - const Component = () => null; + const Component = () =>
; const routeRef = createRouteRef({ path: '/foo', title: 'Foo' }); const extension1 = createComponentExtension({ - component: Component, + component: { + sync: Component, + }, }); const extension2 = createRoutableExtension({ - component: Component, + component: () => Promise.resolve(Component), mountPoint: routeRef, }); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 4087d5f963..e56b6901dd 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -14,59 +14,96 @@ * limitations under the License. */ -import React, { - NamedExoticComponent, - ComponentType, - PropsWithChildren, -} from 'react'; -import { RouteRef } from '../routing'; +import React, { lazy, Suspense } from 'react'; +import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; import { Extension, BackstagePlugin } from '../plugin/types'; -export function createRoutableExtension(options: { - component: ComponentType; +type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + +export function createRoutableExtension< + T extends (props: any) => JSX.Element +>(options: { + component: () => Promise; mountPoint: RouteRef; - // TODO(Rugvip): We want to carry forward the exact props type from the inner component, with - // or without children. ComponentType stops us from doing that though, as it always - // adds children to the props internally. We may want to work around this with custom types. -}): Extension< - NamedExoticComponent> -> { +}): Extension { const { component, mountPoint } = options; return createReactExtension({ - component, + component: { + lazy: () => + component().then(InnerComponent => { + const RoutableExtensionWrapper = ((props: any) => { + // Validate that the routing is wired up correctly in the App.tsx + try { + useRouteRef(mountPoint); + } catch { + throw new Error( + 'Routable extension component was not discovered in the app element tree. ' + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + return ; + }) as T; + return RoutableExtensionWrapper; + }), + }, data: { 'core.mountPoint': mountPoint, }, }); } -export function createComponentExtension(options: { - component: ComponentType; -}): Extension> { +export function createComponentExtension< + T extends (props: any) => JSX.Element +>(options: { component: ComponentLoader }): Extension { const { component } = options; return createReactExtension({ component }); } -export function createReactExtension(options: { - component: ComponentType; +export function createReactExtension< + T extends (props: any) => JSX.Element +>(options: { + component: ComponentLoader; data?: Record; -}): Extension> { - const { component: Component, data = {} } = options; +}): Extension { + const { data = {} } = options; + + let Component: T; + if ('lazy' in options.component) { + const lazyLoader = options.component.lazy; + Component = (lazy(() => + lazyLoader().then(component => ({ default: component })), + ) as unknown) as T; + } else { + Component = options.component.sync; + } + const componentName = + (Component as { displayName?: string }).displayName || + Component.name || + 'Component'; + return { - expose(plugin: BackstagePlugin): NamedExoticComponent { - const Result = (props: Props) => ; + expose(plugin: BackstagePlugin) { + const Result: any = (props: any) => ( + + + + ); attachComponentData(Result, 'core.plugin', plugin); for (const [key, value] of Object.entries(data)) { attachComponentData(Result, key, value); } - const name = Component.displayName || Component.name || 'Component'; - if (name) { - Result.displayName = `Extension(${name})`; - } - return Result as NamedExoticComponent; + Result.displayName = `Extension(${componentName})`; + return Result; }, }; } diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx index a69ee9de77..38571fdcc7 100644 --- a/packages/core-api/src/extensions/traversal.test.tsx +++ b/packages/core-api/src/extensions/traversal.test.tsx @@ -41,11 +41,14 @@ describe('discovery', () => { root, discoverers: [childDiscoverer], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); @@ -85,11 +88,14 @@ describe('discovery', () => { ), ], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts index a1fbdab25a..bdf02d16c8 100644 --- a/packages/core-api/src/extensions/traversal.ts +++ b/packages/core-api/src/extensions/traversal.ts @@ -23,7 +23,7 @@ export type Collector = () => { visit( accumulator: Result, element: ReactElement, - parent: ReactElement, + parent: ReactElement | undefined, context: Context, ): Context; }; @@ -33,7 +33,7 @@ export type Collector = () => { * varying methods to discover child nodes and collect data along the way. */ export function traverseElementTree(options: { - root: ReactElement; + root: ReactNode; discoverers: Discoverer[]; collectors: { [name in keyof Results]: Collector }; }): Results { @@ -52,14 +52,14 @@ export function traverseElementTree(options: { // Internal representation of an element in the tree that we're iterating over type QueueItem = { node: ReactNode; - parent: ReactElement; + parent: ReactElement | undefined; contexts: { [name in string]: unknown }; }; const queue = [ { node: Children.toArray(options.root), - parent: options.root, + parent: undefined, contexts: {}, } as QueueItem, ]; @@ -120,10 +120,10 @@ export function traverseElementTree(options: { } export function createCollector( - initialResult: Result, + accumulatorFactory: () => Result, visit: ReturnType>['visit'], ): Collector { - return () => ({ accumulator: initialResult, visit }); + return () => ({ accumulator: accumulatorFactory(), visit }); } export function childDiscoverer(element: ReactElement): ReactNode { diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx index 488973b664..49db90efcc 100644 --- a/packages/core-api/src/icons/icons.tsx +++ b/packages/core-api/src/icons/icons.tsx @@ -15,25 +15,46 @@ */ import { SvgIconProps } from '@material-ui/core'; -import PeopleIcon from '@material-ui/icons/People'; -import PersonIcon from '@material-ui/icons/Person'; -import React, { FC } from 'react'; +import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; +import MuiChatIcon from '@material-ui/icons/Chat'; +import MuiDashboardIcon from '@material-ui/icons/Dashboard'; +import MuiEmailIcon from '@material-ui/icons/Email'; +import MuiGitHubIcon from '@material-ui/icons/GitHub'; +import MuiHelpIcon from '@material-ui/icons/Help'; +import MuiPeopleIcon from '@material-ui/icons/People'; +import MuiPersonIcon from '@material-ui/icons/Person'; +import MuiWarningIcon from '@material-ui/icons/Warning'; +import React from 'react'; import { useApp } from '../app/AppContext'; -import { IconComponent, SystemIconKey, SystemIcons } from './types'; +import { IconComponent, IconComponentMap, SystemIconKey } from './types'; -export const defaultSystemIcons: SystemIcons = { - user: PersonIcon, - group: PeopleIcon, +export const defaultSystemIcons: IconComponentMap = { + brokenImage: MuiBrokenImageIcon, + chat: MuiChatIcon, + dashboard: MuiDashboardIcon, + email: MuiEmailIcon, + github: MuiGitHubIcon, + group: MuiPeopleIcon, + help: MuiHelpIcon, + user: MuiPersonIcon, + warning: MuiWarningIcon, }; const overridableSystemIcon = (key: SystemIconKey): IconComponent => { - const Component: FC = props => { + const Component = (props: SvgIconProps) => { const app = useApp(); const Icon = app.getSystemIcon(key); - return ; + return Icon ? : ; }; return Component; }; -export const UserIcon = overridableSystemIcon('user'); +export const BrokenImageIcon = overridableSystemIcon('brokenImage'); +export const ChatIcon = overridableSystemIcon('chat'); +export const DashboardIcon = overridableSystemIcon('dashboard'); +export const EmailIcon = overridableSystemIcon('email'); +export const GitHubIcon = overridableSystemIcon('github'); export const GroupIcon = overridableSystemIcon('group'); +export const HelpIcon = overridableSystemIcon('help'); +export const UserIcon = overridableSystemIcon('user'); +export const WarningIcon = overridableSystemIcon('warning'); diff --git a/packages/core-api/src/icons/types.ts b/packages/core-api/src/icons/types.ts index 30e0ba53b2..c4634bd00a 100644 --- a/packages/core-api/src/icons/types.ts +++ b/packages/core-api/src/icons/types.ts @@ -17,6 +17,17 @@ import { ComponentType } from 'react'; import { SvgIconProps } from '@material-ui/core'; +export type SystemIconKey = + | 'brokenImage' + | 'chat' + | 'dashboard' + | 'email' + | 'github' + | 'group' + | 'help' + | 'user' + | 'warning'; + export type IconComponent = ComponentType; -export type SystemIconKey = 'user' | 'group'; -export type SystemIcons = { [key in SystemIconKey]: IconComponent }; +export type IconKey = SystemIconKey | string; +export type IconComponentMap = { [key in IconKey]: IconComponent }; 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/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts index 4572b923dd..5c960f7876 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -36,6 +36,7 @@ class LocalStorage { } class MockManager implements SessionManager { + setSession = jest.fn(); getSession = jest.fn(); removeSession = jest.fn(); sessionState$ = jest.fn(); @@ -59,6 +60,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('a b c'); expect(manager.getSession).not.toHaveBeenCalled(); + expect(manager.setSession).toHaveBeenCalledWith('a b c'); }); it('should not use session without enough scope', async () => { @@ -72,6 +74,7 @@ describe('GheAuth AuthSessionStore', () => { 'a b c d', ); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should not use expired session', async () => { @@ -87,6 +90,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should not load missing session', async () => { @@ -96,6 +100,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); expect(localStorage.getItem('my-key')).toBe('"123"'); }); @@ -109,6 +114,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should clear session', () => { @@ -119,6 +125,7 @@ describe('GheAuth AuthSessionStore', () => { store.removeSession(); expect(localStorage.getItem('my-key')).toBe(null); + expect(manager.removeSession).toHaveBeenCalled(); }); it('should forward sessionState calls', () => { diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index e82557b1ce..224036d283 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -16,6 +16,7 @@ import { SessionManager, + MutableSessionManager, SessionScopesFunc, SessionShouldRefreshFunc, GetSessionOptions, @@ -24,7 +25,7 @@ import { SessionScopeHelper } from './common'; type Options = { /** The connector used for acting on the auth session */ - manager: SessionManager; + manager: MutableSessionManager; /** Storage key to use to store sessions */ storageKey: string; /** Used to get the scope of the session */ @@ -40,7 +41,7 @@ type Options = { * Session is serialized to JSON with special support for following types: Set. */ export class AuthSessionStore implements SessionManager { - private readonly manager: SessionManager; + private readonly manager: MutableSessionManager; private readonly storageKey: string; private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; private readonly helper: SessionScopeHelper; @@ -70,6 +71,7 @@ export class AuthSessionStore implements SessionManager { const shouldRefresh = this.sessionShouldRefreshFunc(session!); if (!shouldRefresh) { + this.manager.setSession(session!); return session!; } } diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index e02b600828..e4f144b0a9 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionManager, GetSessionOptions } from './types'; +import { MutableSessionManager, GetSessionOptions } from './types'; import { AuthConnector } from '../AuthConnector'; import { SessionScopeHelper } from './common'; import { SessionStateTracker } from './SessionStateTracker'; @@ -31,7 +31,7 @@ type Options = { /** * StaticAuthSessionManager manages an underlying session that does not expire. */ -export class StaticAuthSessionManager implements SessionManager { +export class StaticAuthSessionManager implements MutableSessionManager { private readonly connector: AuthConnector; private readonly helper: SessionScopeHelper; private readonly stateTracker = new SessionStateTracker(); @@ -45,6 +45,11 @@ export class StaticAuthSessionManager implements SessionManager { this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); } + setSession(session: T | undefined): void { + this.currentSession = session; + this.stateTracker.setIsSignedIn(Boolean(session)); + } + async getSession(options: GetSessionOptions): Promise { if ( this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index 804c7121e1..f57afd6760 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -36,6 +36,13 @@ export type SessionManager = { sessionState$(): Observable; }; +/** + * An extension of the session manager where the session can also be pushed from the manager. + */ +export interface MutableSessionManager extends SessionManager { + setSession(session: T | undefined): void; +} + /** * A function called to determine the scopes of a session. */ diff --git a/packages/core-api/src/lib/globalObject.test.ts b/packages/core-api/src/lib/globalObject.test.ts new file mode 100644 index 0000000000..f539483a83 --- /dev/null +++ b/packages/core-api/src/lib/globalObject.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { getGlobalSingleton } from './globalObject'; + +const anyGlobal = global as any; + +describe('getGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + anyGlobal['__@backstage/my-thing__'] = myThing; + + expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + }); + + it('should should create a new value', () => { + const myNewThing = {}; + + expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); + expect(getGlobalSingleton('my-thing', () => myNewThing)).toBe(myNewThing); + expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); + expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); diff --git a/packages/core-api/src/lib/globalObject.ts b/packages/core-api/src/lib/globalObject.ts new file mode 100644 index 0000000000..c7f9f98882 --- /dev/null +++ b/packages/core-api/src/lib/globalObject.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ + +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +function getGlobalObject() { + if (typeof window !== 'undefined' && window.Math === Math) { + return window; + } + if (typeof self !== 'undefined' && self.Math === Math) { + return self; + } + // eslint-disable-next-line no-new-func + return Function('return this')(); +} + +export const globalObject = getGlobalObject(); + +export function getGlobalSingleton(id: string, supplier: () => T): T { + const key = `__@backstage/${id}__`; + + let value = globalObject[key]; + if (value) { + return value; + } + + value = supplier(); + globalObject[key] = value; + return value; +} 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 f477674f4d..cc168707be 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -19,13 +19,18 @@ import { PluginOutput, BackstagePlugin, Extension, + AnyRoutes, + AnyExternalRoutes, } from './types'; import { AnyApiFactory } from '../apis'; -export class PluginImpl implements BackstagePlugin { +export class PluginImpl< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> implements BackstagePlugin { private storedOutput?: PluginOutput[]; - constructor(private readonly config: PluginConfig) {} + constructor(private readonly config: PluginConfig) {} getId(): string { return this.config.id; @@ -35,6 +40,14 @@ export class PluginImpl implements BackstagePlugin { return this.config.apis ?? []; } + get routes(): Routes { + return this.config.routes ?? ({} as Routes); + } + + get externalRoutes(): ExternalRoutes { + return this.config.externalRoutes ?? ({} as ExternalRoutes); + } + output(): PluginOutput[] { if (this.storedOutput) { return this.storedOutput; @@ -55,9 +68,6 @@ export class PluginImpl implements BackstagePlugin { options, }); }, - registerRoute(path, component, options) { - outputs.push({ type: 'legacy-route', path, component, options }); - }, }, featureFlags: { register(name) { @@ -79,6 +89,11 @@ export class PluginImpl implements BackstagePlugin { } } -export function createPlugin(config: PluginConfig): BackstagePlugin { +export function createPlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +>( + config: PluginConfig, +): BackstagePlugin { return new PluginImpl(config); } diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx index f040cfd433..5baf2539ab 100644 --- a/packages/core-api/src/plugin/collectors.test.tsx +++ b/packages/core-api/src/plugin/collectors.test.tsx @@ -30,7 +30,9 @@ import { import { pluginCollector } from './collectors'; const mockConfig = () => ({ path: '/foo', title: 'Foo' }); -const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); const pluginA = createPlugin({ id: 'my-plugin-a' }); const pluginB = createPlugin({ id: 'my-plugin-b' }); @@ -40,19 +42,25 @@ const ref1 = createRouteRef(mockConfig()); const ref2 = createRouteRef(mockConfig()); const Extension1 = pluginA.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref1 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), ); const Extension2 = pluginB.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref2 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), ); const Extension3 = pluginA.provide( - createComponentExtension({ component: MockComponent }), + createComponentExtension({ component: { sync: MockComponent } }), ); const Extension4 = pluginB.provide( - createComponentExtension({ component: MockComponent }), + createComponentExtension({ component: { sync: MockComponent } }), ); const Extension5 = pluginC.provide( - createComponentExtension({ component: MockComponent }), + createComponentExtension({ component: { sync: MockComponent } }), ); describe('collection', () => { diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts index a222746f9e..b04c7b41c4 100644 --- a/packages/core-api/src/plugin/collectors.ts +++ b/packages/core-api/src/plugin/collectors.ts @@ -34,9 +34,12 @@ import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; export const pluginCollector = createCollector( - new Set(), + () => new Set>(), (acc, node) => { - const plugin = getComponentData(node, 'core.plugin'); + const plugin = getComponentData>( + node, + 'core.plugin', + ); if (plugin) { acc.add(plugin); } diff --git a/packages/core-api/src/plugin/index.ts b/packages/core-api/src/plugin/index.ts index 79b0575755..bbeeca4824 100644 --- a/packages/core-api/src/plugin/index.ts +++ b/packages/core-api/src/plugin/index.ts @@ -15,4 +15,19 @@ */ export { createPlugin } from './Plugin'; -export * from './types'; +export type { + BackstagePlugin, + Extension, + FeatureFlagOutput, + FeatureFlagsHooks, + LegacyRedirectRouteOutput, + LegacyRouteOutput, + PluginConfig, + PluginHooks, + PluginOutput, + RedirectRouteOutput, + RouteOptions, + RouteOutput, + RoutePath, + RouterHooks, +} from './types'; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index dd19948a0a..711246f1f4 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -17,6 +17,7 @@ import { ComponentType } from 'react'; import { RouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; +import { ExternalRouteRef } from '../routing/RouteRef'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. @@ -67,43 +68,53 @@ export type PluginOutput = | FeatureFlagOutput; export type Extension = { - expose(plugin: BackstagePlugin): T; + expose(plugin: BackstagePlugin): T; }; -export type BackstagePlugin = { +export type AnyRoutes = { [name: string]: RouteRef }; + +export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; + +export type BackstagePlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +> = { getId(): string; output(): PluginOutput[]; getApis(): Iterable; provide(extension: Extension): T; + routes: Routes; + externalRoutes: ExternalRoutes; }; -export type PluginConfig = { +export type PluginConfig< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> = { id: string; apis?: Iterable; register?(hooks: PluginHooks): void; + routes?: Routes; + externalRoutes?: ExternalRoutes; }; 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/public.ts b/packages/core-api/src/public.ts index 0a30936c52..f91d97c31d 100644 --- a/packages/core-api/src/public.ts +++ b/packages/core-api/src/public.ts @@ -16,6 +16,7 @@ export * from './apis'; export * from './app'; +export * from './extensions'; export * from './icons'; export * from './plugin'; export * from './routing'; diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx new file mode 100644 index 0000000000..3702442811 --- /dev/null +++ b/packages/core-api/src/routing/FlatRoutes.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { useApp } from '../app'; + +type RouteObject = { + path: string; + element: JSX.Element; + children?: RouteObject[]; +}; + +// Similar to the same function from react-router, this collects routes from the +// children, but only the first level of routes +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode) + .flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + const { children } = child.props; + + if (child.type === Fragment) { + return createRoutesFromChildren(children); + } + + let path = child.props.path as string | undefined; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: children && [ + { + path: '/*', + element: children, + }, + ], + }, + ]; + }) + .sort((a, b) => b.path.localeCompare(a.path)) + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }); +} + +type FlatRoutesProps = { + children: ReactNode; +}; + +export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + const routes = createRoutesFromChildren(props.children); + + // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop + routes.push({ + element: , + path: '/*', + }); + + return useRoutes(routes); +}; diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index c33335cb38..67ff9bc1fb 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,44 +14,19 @@ * limitations under the License. */ -import { - ConcreteRoute, - routeReference, - ReferencedRoute, - resolveRoute, - RouteRefConfig, -} from './types'; -import { generatePath } from 'react-router-dom'; +import { RouteRef } from './types'; +import { IconComponent } from '../icons'; -type SubRouteConfig = { - path: string; +export type RouteRefConfig = { + params?: Array; + /** @deprecated Route refs no longer decide their own path */ + path?: string; + icon?: IconComponent; + title: string; }; -export class SubRouteRef - implements ReferencedRoute { - constructor( - private readonly parent: ConcreteRoute, - private readonly config: SubRouteConfig, - ) {} - - get [routeReference]() { - return this; - } - - link(...args: Args): ConcreteRoute { - return { - [routeReference]: this, - [resolveRoute]: (path: string) => { - const ownPart = generatePath(this.config.path, args[0] ?? {}); - const parentPart = this.parent[resolveRoute](path); - return parentPart + ownPart; - }, - }; - } -} - -export class AbsoluteRouteRef implements ConcreteRoute { - constructor(private readonly config: RouteRefConfig) {} +export class AbsoluteRouteRef { + constructor(private readonly config: RouteRefConfig) {} get icon() { return this.config.icon; @@ -59,33 +34,66 @@ export class AbsoluteRouteRef implements ConcreteRoute { // TODO(Rugvip): Remove this, routes are looked up via the registry instead get path() { - return this.config.path; + return this.config.path ?? ''; } get title() { return this.config.title; } - createSubRoute( - config: SubRouteConfig, - ) { - return new SubRouteRef(this, config); - } - - get [routeReference]() { - return this; - } - - [resolveRoute](path: string) { - return path; + toString() { + return `routeRef{title=${this.title}}`; } } -export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { - return new AbsoluteRouteRef(config); +export function createRouteRef< + // 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); } -// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone -// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider -// a different model for how to create sub routes, just avoid this -export type MutableRouteRef = AbsoluteRouteRef; +export class ExternalRouteRef { + readonly optional: boolean; + + private constructor({ id, optional }: ExternalRouteRefOptions) { + this.toString = () => `externalRouteRef{${id}}`; + this.optional = Boolean(optional); + } +} + +export type ExternalRouteRefOptions = { + /** + * An identifier for this route, used to identify it in error messages + */ + id: string; + + /** + * Whether or not this route is optional, defaults to false. + * + * Optional external routes are not required to be bound in the app, and + * if they aren't, `useRouteRef` will return `undefined`. + */ + optional?: Optional; +}; + +export function createExternalRouteRef( + options: ExternalRouteRefOptions, +): ExternalRouteRef { + return new ((ExternalRouteRef as unknown) as { + new (options: ExternalRouteRefOptions): ExternalRouteRef< + Optional + >; + })(options); +} diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts deleted file mode 100644 index fa1ef584f1..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ /dev/null @@ -1,105 +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 { RouteRefRegistry } from './RouteRefRegistry'; -import { createRouteRef } from './RouteRef'; - -const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; -const ref1 = createRouteRef(dummyConfig); -const ref11 = createRouteRef(dummyConfig); -const ref12 = createRouteRef(dummyConfig); -const ref121 = createRouteRef(dummyConfig); -const ref2 = createRouteRef(dummyConfig); -const ref2a = ref2.createSubRoute({ path: '/a' }); -const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); - -describe('RouteRefRegistry', () => { - it('should be constructed with a root route', () => { - const registry = new RouteRefRegistry(); - expect(registry.resolveRoute([], [])).toBe(''); - }); - - it('should register and resolve some absolute routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); - expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( - false, - ); - expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref11])).toBe(undefined); - expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); - expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); - }); - - it('should register and resolve with sub routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2a], '2')).toBe(true); - expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2b], '2')).toBe(true); - expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref2])).toBe('/2'); - expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); - expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( - '/2/b/abc/1', - ); - expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( - '/2/b/xyz/2', - ); - expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( - '/2/b/abc/2', - ); - expect( - registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), - ).toBe('/2/b/abc/2'); - }); - - it('should throw when registering routes incorrectly', () => { - const registry = new RouteRefRegistry(); - expect(() => { - registry.registerRoute([ref1, ref11], '11'); - }).toThrow('Could not find parent for new routing node'); - expect(() => { - registry.registerRoute([], '11'); - }).toThrow('Must provide at least 1 route to add routing node'); - }); -}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts deleted file mode 100644 index 7e55cbe8f7..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ /dev/null @@ -1,155 +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 { - ConcreteRoute, - routeReference, - resolveRoute, - ReferencedRoute, -} from './types'; - -const rootRoute: ConcreteRoute = { - get [routeReference]() { - return this; - }, - [resolveRoute]: () => '', -}; - -export type RouteRefResolver = { - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; -}; - -class Node { - readonly children = new Map(); - - constructor(readonly path: string, readonly parent: Node | undefined) {} - - /** - * Look up a node in the tree given a path. - */ - findNode(routes: ReferencedRoute[]): Node | undefined { - let node = this as Node | undefined; - - for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i][routeReference]); - } - - return node; - } - - /** - * Assigns a path to a leaf node in the routing tree. All ancestor - * nodes of the new leaf node must already exist, or an error will be thrown. - * - * Returns true if the node was added, or false if the node already existed. - */ - addNode(routes: ReferencedRoute[], path: string): boolean { - if (routes.length === 0) { - throw new Error('Must provide at least 1 route to add routing node'); - } - - const parentNode = this.findNode(routes.slice(0, -1)); - if (!parentNode) { - throw new Error('Could not find parent for new routing node'); - } - - const lastRoute = routes[routes.length - 1]; - const lastRouteRef = lastRoute[routeReference]; - - const existingNode = parentNode.children.get(lastRouteRef); - if (existingNode) { - return existingNode.path === path; - } - - parentNode.children.set(lastRouteRef, new Node(path, parentNode)); - return true; - } - - /** - * Resolve an absolute URL that represents this node in the routing tree, using - * using the supplied concrete routes and ancestors of this node. - * - * The length of the provided routes array must match the depth of - * the routing tree that this node is at, or an error will be thrown. - */ - resolve(routes: ConcreteRoute[]) { - const parts = Array(routes.length); - - let node = this as Node | undefined; - for (let i = routes.length - 1; i >= 0; i--) { - if (!node) { - throw new Error('Route resolve missing required parent'); - } - - const route = routes[i]; - parts[i] = route[resolveRoute](node.path); - - node = node.parent; - } - - if (node) { - throw new Error('Route resolve did not reach root'); - } - - return parts.join('/'); - } -} - -/** - * A registry for resolving route refs into concrete string routes. - */ -export class RouteRefRegistry { - private readonly root = new Node('', undefined); - - /** - * Register a new leaf path for a sequence of routes. All ancestor - * routes must already exist. - */ - registerRoute(routes: ReferencedRoute[], path: string): boolean { - return this.root.addNode(routes, path); - } - - /** - * Resolve an absolute path from a point in the routing tree. - * - * The route referenced by `from` must exist, and is the starting - * point for the search, walking up the tree until a subtree that - * matches the routes reference in `to` are found. - * - * If `from` is empty, the search starts and ends at the root node. - * If `to` is empty, the route referenced by `from` will always be returned. - */ - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { - // Keep track of the `from` routes and pop the last ones as we traverse up - // the routing tree. The list of concrete routes that we're passing to - // `node.resolve()` should only include the ones in the resolve path. - const concreteStack = from.slice(); - - let fromNode = this.root.findNode(from); - while (fromNode) { - const resolvedNode = fromNode.findNode(to); - if (resolvedNode) { - return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); - } - - // Search at this level of the tree failed, move up to parent - concreteStack.pop(); - fromNode = fromNode.parent; - } - - return undefined; - } -} diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index c2eefa7e82..44d53856b4 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -15,7 +15,11 @@ */ import React, { PropsWithChildren } from 'react'; -import { routeCollector, routeParentCollector } from './collectors'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; import { traverseElementTree, @@ -24,36 +28,78 @@ import { } from '../extensions/traversal'; import { createRouteRef } from './RouteRef'; import { createPlugin } from '../plugin'; -import { createRoutableExtension } from '../extensions'; +import { attachComponentData, createRoutableExtension } from '../extensions'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RouteRef } from './types'; -const mockConfig = () => ({ path: '/foo', title: 'Foo' }); -const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); const plugin = createPlugin({ id: 'my-plugin' }); -const ref1 = createRouteRef(mockConfig()); -const ref2 = createRouteRef(mockConfig()); -const ref3 = createRouteRef(mockConfig()); -const ref4 = createRouteRef(mockConfig()); -const ref5 = createRouteRef(mockConfig()); +const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); +const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); +const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); +const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; const Extension1 = plugin.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref1 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), ); const Extension2 = plugin.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref2 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), ); const Extension3 = plugin.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref3 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), ); const Extension4 = plugin.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref4 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref4, + }), ); const Extension5 = plugin.provide( - createRoutableExtension({ component: MockComponent, mountPoint: ref5 }), + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), ); +const AggregationComponent = ({ + children, +}: PropsWithChildren<{ + path: string; +}>) => <>{children}; + +attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +function routeObj(path: string, refs: RouteRef[], children: any[] = []) { + return { + path: path, + caseSensitive: false, + element: null, + routeRefs: new Set(refs), + children: children, + }; +} + describe('discovery', () => { it('should collect routes', () => { const list = [ @@ -92,33 +138,40 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routes, routeParents, routeObjects } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, + routeObjects: routeObjectCollector, }, }); - expect(routes).toEqual( - new Map([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]), - ); - - expect(routeParents).toEqual( - new Map([ - [ref1, undefined], - [ref2, ref1], - [ref3, ref2], - [ref4, undefined], - [ref5, ref1], - ]), - ); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); }); it('should handle all react router Route patterns', () => { @@ -147,28 +200,158 @@ describe('discovery', () => { root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }); - expect(routes).toEqual( - new Map([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]), + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
); - expect(routeParents).toEqual( - new Map([ - [ref1, undefined], - [ref2, ref1], - [ref3, undefined], - [ref4, ref3], - [ref5, ref3], - ]), + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2]), + routeObj('/bar', [ref3], [routeObj('/baz', [ref4, ref5])]), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); }); it('should not visit the same element twice', () => { @@ -184,10 +367,10 @@ describe('discovery', () => { ), discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }), - ).toThrow(`Visited element Extension(MockComponent) twice`); + ).toThrow(`Visited element Extension(Component) twice`); }); }); diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index fab0cbe112..37362cf8d4 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -14,69 +14,148 @@ * limitations under the License. */ -import { isValidElement, ReactNode } from 'react'; -import { RouteRef } from '../routing/types'; +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { BackstageRouteObject, RouteRef } from '../routing/types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; -export const routeCollector = createCollector( - new Map(), - (acc, node, parent) => { - if (parent.props.element === node) { - return; +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), + (acc, node, parent, ctxPath: string | undefined) => { + // The context path is used during mount point gathering to assign the same path + // to all discovered mount points + let currentCtxPath = ctxPath; + + if (parent?.props.element === node) { + return currentCtxPath; } - const path: string | undefined = node.props?.path; - const element: ReactNode = node.props?.element; + // Start gathering mount points when we encounter a mount point gathering flag + if (getComponentData(node, 'core.gatherMountPoints')) { + const path: string | undefined = node.props?.path; + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + currentCtxPath = path; + } - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { + let path: string | undefined = node.props?.path; + // If we're gathering mount points we use the context path as out path, unless + // the element has its own path, in which case we use that instead and stop gathering + if (currentCtxPath) { + if (path) { + currentCtxPath = undefined; + } else { + path = currentCtxPath; + } + } if (!path) { throw new Error('Mounted routable extension must have a path'); } acc.set(routeRef, path); - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - if (elementRouteRef) { - if (!path) { - throw new Error('Route element must have a path'); - } - acc.set(elementRouteRef, path); - } } + return currentCtxPath; }, ); export const routeParentCollector = createCollector( - new Map(), - (acc, node, parent, parentRouteRef?: RouteRef) => { - if (parent.props.element === node) { + () => new Map(), + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { + if (parent?.props.element === node) { return parentRouteRef; } - const element: ReactNode = node.props?.element; - let nextParent = parentRouteRef; - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { - acc.set(routeRef, parentRouteRef); - nextParent = routeRef; - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); + // "sticky" route ref is when we've encountered a mount point gatherer, and we want a + // mount points beneath it to have the same parent, regardless of internal structure + if (parentRouteRef && 'sticky' in parentRouteRef) { + acc.set(routeRef, parentRouteRef.sticky); - if (elementRouteRef) { - acc.set(elementRouteRef, parentRouteRef); - nextParent = elementRouteRef; + // When we encounter a mount point with an explicit path, we stop gathering + // mount points withing the children and remove the sticky state + if (node.props?.path) { + nextParent = routeRef; + } else { + nextParent = parentRouteRef; + } + } else { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; } } + // Mount point gatherers are marked as "sticky" + if (getComponentData(node, 'core.gatherMountPoints')) { + return { sticky: nextParent }; + } + return nextParent; }, ); + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; + if (parent?.props.element === node) { + return parentObj; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set([routeRef]), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + parentObj?.routeRefs.add(routeRef); + } + + const isGatherer = getComponentData( + node, + 'core.gatherMountPoints', + ); + if (isGatherer) { + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set(), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + return parentObj; + }, +); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx new file mode 100644 index 0000000000..94cd3a704d --- /dev/null +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -0,0 +1,312 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, { PropsWithChildren, ReactElement } from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { createPlugin } from '../plugin'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { + useRouteRef, + RoutingProvider, + validateRoutes, + RouteFunc, +} from './hooks'; +import { + createRouteRef, + createExternalRouteRef, + ExternalRouteRef, + RouteRefConfig, +} from './RouteRef'; +import { AnyRouteRef, RouteRef } from './types'; + +const mockConfig = (extra?: Partial>) => ({ + path: '/unused', + title: 'Unused', + ...extra, +}); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); +const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); +const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); +const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); +const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); +const eRefA = createExternalRouteRef({ id: '1' }); +const eRefB = createExternalRouteRef({ id: '2' }); +const eRefC = createExternalRouteRef({ id: '3' }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ id: '5', optional: true }); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: AnyRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; + return ( +
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, ref3], + [eRefB, ref1], + [eRefC, ref2], + [eRefD, ref1], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routePaths, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routePaths, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); +}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx new file mode 100644 index 0000000000..54490fb4f2 --- /dev/null +++ b/packages/core-api/src/routing/hooks.tsx @@ -0,0 +1,208 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, { createContext, ReactNode, useContext, useMemo } from 'react'; +import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types'; +import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; +import { ExternalRouteRef } from './RouteRef'; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params[keyof Params] extends never + ? readonly [] + : readonly [Params] +) => string; + +class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map, + ) {} + + resolve( + routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, + sourceLocation: ReturnType, + ): RouteFunc | undefined { + const routeRef = + this.routeBindings.get(routeRefOrExternalRouteRef) ?? + (routeRefOrExternalRouteRef as RouteRef); + + const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; + + // If our route isn't bound to a path we fail the resolution and let the caller decide the failure mode + const lastPath = this.routePaths.get(routeRef); + if (!lastPath) { + return undefined; + } + const targetRefStack = Array(); + let matchIndex = -1; + + for ( + let currentRouteRef: AnyRouteRef | undefined = routeRef; + currentRouteRef; + currentRouteRef = this.routeParents.get(currentRouteRef) + ) { + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!), + ); + if (matchIndex !== -1) { + break; + } + + targetRefStack.unshift(currentRouteRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (targetRefStack.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the called would have no way of knowing + // what parameters those are. + const prefixPath = targetRefStack + .slice(0, -1) + .map(ref => { + const path = this.routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${routeRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }) + .join('/') + .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s + + const routeFunc: RouteFunc = (...[params]) => { + return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; + }; + return routeFunc; + } +} + +const RoutingContext = createContext(undefined); + +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>; +export function useRouteRef( + routeRef: RouteRef, +): RouteFunc; +export function useRouteRef( + routeRef: RouteRef | ExternalRouteRef, +): RouteFunc | undefined { + const sourceLocation = useLocation(); + const resolver = useContext(RoutingContext); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!routeFunc && !resolver) { + throw new Error('No route resolver found in context'); + } + + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!routeFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + + return routeFunc; +} + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + ); + return ( + + {children} + + ); +}; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 29de34ec42..100158c3c5 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,17 @@ * limitations under the License. */ -export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; -export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; -export { createRouteRef } from './RouteRef'; +export type { + RouteRef, + AbsoluteRouteRef, + ConcreteRoute, + MutableRouteRef, +} from './types'; +export { FlatRoutes } from './FlatRoutes'; +export { + createRouteRef, + createExternalRouteRef, + ExternalRouteRef, +} 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 162ac74bde..b21fa38052 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -15,27 +15,42 @@ */ import { IconComponent } from '../icons'; +import { ExternalRouteRef } from './RouteRef'; -export const resolveRoute = Symbol('resolve-route'); -export const routeReference = Symbol('route-ref'); - -export type ReferencedRoute = { - [routeReference]: unknown; -}; - -export type ConcreteRoute = ReferencedRoute & { - [resolveRoute](path: string): string; -}; - -export type RouteRef = { +// @ts-ignore, we're just embedding the Params type for usage in other places +export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead + /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ path: string; icon?: IconComponent; title: string; }; -export type RouteRefConfig = { +export type AnyRouteRef = RouteRef | ExternalRouteRef; + +/** + * This type should not be used + * @deprecated + */ +export type ConcreteRoute = {}; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type AbsoluteRouteRef = RouteRef<{}>; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type MutableRouteRef = RouteRef<{}>; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; path: string; - icon?: IconComponent; - title: string; -}; + routeRefs: Set; +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d29bc53452..81fe728101 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,203 @@ # @backstage/core +## 0.6.3 + +### Patch Changes + +- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched. +- e799e74d4: Fix `OverflowTooltip` cutting off the bottom of letters like "g" and "y". +- 1407b34c6: More informative error message for missing ApiContext. +- 9615e68fb: Forward link styling of `EntityRefLink` and `EnriryRefLinks` into the underling + `Link`. +- 49f9b7346: Deprecate `type` of `ItemCard` and introduce new `subtitle` which allows passing + react nodes. +- 3a58084b6: Created separate `AppContext` type to be returned from `useApp` rather than the `BackstageApp` itself. The `AppContext` type includes but deprecates `getPlugins`, `getProvider`, `getRouter`, and `getRoutes`. In addition, the `AppContext` adds a new `getComponents` method which providers access to the app components. +- 2c1f2a7c2: Introduced generic OverflowTooltip component for cases where longer text needs to be truncated with ellipsis and show hover tooltip with full text. This is particularly useful in the cases where longer description text is rendered in table. e.g. CatalogTable and ApiExplorerTable. +- Updated dependencies [3a58084b6] +- Updated dependencies [1407b34c6] +- Updated dependencies [b6c4f485d] +- Updated dependencies [3a58084b6] +- Updated dependencies [a1f5e6545] + - @backstage/core-api@0.2.11 + - @backstage/config@0.1.3 + +## 0.6.2 + +### Patch Changes + +- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`. +- f4c2bcf54: Use a more strict type for `variant` of cards. +- 07e226872: Export Select component +- f62e7abe5: Make sure that SidebarItems are also active when on sub route. +- 96f378d10: Add support for custom empty state of `Table` components. + + You can now optionally pass `emptyContent` to `Table` that is displayed + if the table has now rows. + +- 688b73110: Add Breadcrumbs component +- Updated dependencies [f10950bd2] +- Updated dependencies [fd3f2a8c0] + - @backstage/core-api@0.2.10 + +## 0.6.1 + +### Patch Changes + +- b51ee6ece: Fixed type inference of `createRouteRef`. + +## 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 + +- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. +- Updated dependencies [a08c32ced] +- Updated dependencies [86c3c652a] +- Updated dependencies [27f2af935] + - @backstage/core-api@0.2.8 + +## 0.4.2 + +### Patch Changes + +- 1dc445e89: Update to use new plugin extension API +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + +## 0.4.1 + +### Patch Changes + +- 8ef71ed32: Add a `` component to `@backstage/core`. +- Updated dependencies [7dd2ef7d1] + - @backstage/core-api@0.2.6 + +## 0.4.0 + +### Minor Changes + +- ff243ce96: Introducing a new optional property within `app-config.yaml` called `auth.environment` to have configurable environment value for `auth.providers` + + **Default Value:** 'development' + + **Optional Values:** 'production' | 'development' + + **Migration-steps:** + + - To override the default value, one could simply introduce the new property `environment` within the `auth` section of the `config.yaml` + - re-run the build to reflect the changed configs + +### Patch Changes + +- 2527628e1: Link `component` prop now accepts any element type. +- 1c69d4716: Fix React warning of descendant paragraph tag +- 04f26f88d: Export the `defaultConfigLoader` implementation +- Updated dependencies [b6557c098] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [d8d5a17da] +- Updated dependencies [1665ae8bb] +- Updated dependencies [e3bd9fc2f] + - @backstage/core-api@0.2.5 + - @backstage/config@0.1.2 + - @backstage/theme@0.2.2 + ## 0.3.2 ### Patch Changes @@ -21,7 +219,7 @@ ### Patch Changes - 7b37d65fd: Adds the MarkdownContent component to render and display Markdown content with the default - [GFM](https://github.github.com/gfm/) (Github flavored Markdown) dialect. + [GFM](https://github.github.com/gfm/) (GitHub Flavored Markdown) dialect. ``` @@ -59,7 +257,7 @@ - 482b6313d: Fix dense in Structured Metadata Table - 1c60f716e: Added EmptyState component -- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the default scope is configurable when overwriting the Core Api in the app. ``` diff --git a/packages/core/README.md b/packages/core/README.md index 0d0063c9fd..6d8519d46f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ This package provides the core API used by Backstage plugins and apps. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/core diff --git a/packages/core/config.d.ts b/packages/core/config.d.ts index 014e4ac934..9e43c6f3ad 100644 --- a/packages/core/config.d.ts +++ b/packages/core/config.d.ts @@ -30,6 +30,41 @@ export interface Config { * @visibility frontend */ title?: string; + + /** + * Information about support of this Backstage instance and how to contact the integrator team. + */ + support?: { + /** + * The primary support url. + * @visibility frontend + */ + url: string; + /** + * A list of categorized support item groupings. + */ + items: { + /** + * The title of the support item grouping. + * @visibility frontend + */ + title: string; + /** + * An optional icon for the support item grouping. + * @visibility frontend + */ + icon?: string; + /** + * A list of support links for the Backstage instance. + */ + links: { + /** @visibility frontend */ + url: string; + /** @visibility frontend */ + title?: string; + }[]; + }[]; + }; }; /** @@ -62,4 +97,17 @@ export interface Config { timezone: string; }[]; }; + + /** + * Configuration that provides information on available authentication providers configured for app + */ + auth?: { + /** + * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`. + * default value: 'development' + * optional values: 'development' | 'production' + * @visibility frontend + */ + environment?: string; + }; } diff --git a/packages/core/package.json b/packages/core/package.json index dd48a5d4ce..2de07ebcca 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.3.2", + "version": "0.6.3", "private": false, "publishConfig": { "access": "public", @@ -29,26 +29,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/core-api": "^0.2.4", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.3", + "@backstage/core-api": "^0.2.11", + "@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", "@types/react-sparklines": "^1.7.0", + "@types/react-text-truncate": "^0.14.0", "classnames": "^2.2.6", "clsx": "^1.1.0", "d3-selection": "^2.0.0", "d3-shape": "^2.0.0", "d3-zoom": "^2.0.0", "dagre": "^0.8.5", - "qs": "^6.9.4", - "immer": "^7.0.9", + "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", + "qs": "^6.9.4", "rc-progress": "^3.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -59,15 +62,16 @@ "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^13.5.1", + "react-text-truncate": "^0.16.0", "react-use": "^15.3.3", "remark-gfm": "^1.0.0", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/classnames": "^2.2.9", "@types/d3-selection": "^2.0.0", diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 4a1e58db42..9a8f7a36df 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import privateExports, { AppOptions, defaultSystemIcons, @@ -93,7 +93,7 @@ export function createApp(options?: AppOptions) { const DefaultNotFoundPage = () => ( ); - const DefaultBootErrorPage: FC = ({ step, error }) => { + const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => { let message = ''; if (step === 'load-config') { message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; @@ -142,6 +142,7 @@ export function createApp(options?: AppOptions) { themes, configLoader, defaultApis, + bindRoutes: options?.bindRoutes, }); app.verify(); diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index 1f18f54d2a..d044b50b7a 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -78,30 +78,42 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GoogleAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: microsoftAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - MicrosoftAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: githubAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => GithubAuth.create({ discoveryApi, oauthRequestApi, defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), }), }), createApiFactory({ @@ -109,60 +121,91 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OktaAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: gitlabAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GitlabAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: auth0AuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - Auth0Auth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + Auth0Auth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oauth2ApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OAuth2.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: samlAuthApiRef, deps: { discoveryApi: discoveryApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), + factory: ({ discoveryApi, configApi }) => + SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oneloginAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OneLoginAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oidcAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => OAuth2.create({ discoveryApi, oauthRequestApi, @@ -171,6 +214,7 @@ export const defaultApis = [ title: 'Your Identity Provider', icon: OAuth2Icon, }, + environment: configApi.getOptionalString('auth.environment'), }), }), ]; diff --git a/packages/core/src/api-wrappers/index.ts b/packages/core/src/api-wrappers/index.ts index b8136305b0..42c423d868 100644 --- a/packages/core/src/api-wrappers/index.ts +++ b/packages/core/src/api-wrappers/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createApp } from './createApp'; +export { createApp, defaultConfigLoader } from './createApp'; diff --git a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx index 30940f68ac..6d6646fa18 100644 --- a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx +++ b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx @@ -14,16 +14,14 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Snackbar, IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import { Alert } from '@material-ui/lab'; import { AlertMessage, useApi, alertApiRef } from '@backstage/core-api'; -type Props = {}; - // TODO: improve on this and promote to a shared component for use by all apps. -export const AlertDisplay: FC = () => { +export const AlertDisplay = () => { const [messages, setMessages] = useState>([]); const alertApi = useApi(alertApiRef); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/packages/core/src/components/Avatar/Avatar.stories.tsx similarity index 50% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx rename to packages/core/src/components/Avatar/Avatar.stories.tsx index f28989d745..5ac628d72b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/packages/core/src/components/Avatar/Avatar.stories.tsx @@ -13,22 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, Fragment } from 'react'; -import { Paper, Divider } from '@material-ui/core'; -import { AlertActionCard } from './AlertActionCard'; -import { Alert } from '../../types'; -type AlertActionCardList = { - alerts: Array; +import React from 'react'; +import { Avatar } from './Avatar'; + +export default { + title: 'Data Display/Avatar', + component: Avatar, }; -export const AlertActionCardList: FC = ({ alerts }) => ( - - {alerts.map((alert, index) => ( - - - {index < alerts.length - 1 && } - - ))} - +export const Default = () => ( + +); + +export const NameFallback = () => ; + +export const Empty = () => ; + +export const CustomStyling = () => ( + ); diff --git a/plugins/explore/src/components/Router.tsx b/packages/core/src/components/Avatar/Avatar.test.tsx similarity index 68% rename from plugins/explore/src/components/Router.tsx rename to packages/core/src/components/Avatar/Avatar.test.tsx index becb2522d0..da6ca8f42e 100644 --- a/plugins/explore/src/components/Router.tsx +++ b/packages/core/src/components/Avatar/Avatar.test.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ +import { render } from '@testing-library/react'; import React from 'react'; -import { Route, Routes } from 'react-router'; -import { ExplorePluginPage } from './ExplorePluginPage'; -import { rootRouteRef } from '../plugin'; +import { Avatar } from './Avatar'; -export const Router = () => ( - - } /> - -); +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render(); + + expect(getByText('JD')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Avatar/Avatar.tsx b/packages/core/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..95aa4a8ced --- /dev/null +++ b/packages/core/src/components/Avatar/Avatar.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { CSSProperties } from 'react'; +import { + Avatar as MaterialAvatar, + createStyles, + makeStyles, + Theme, +} from '@material-ui/core'; +import { extractInitials, stringToColor } from './utils'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + avatar: { + width: '4rem', + height: '4rem', + color: '#fff', + fontWeight: theme.typography.fontWeightBold, + letterSpacing: '1px', + textTransform: 'uppercase', + }, + }), +); + +export type AvatarProps = { + displayName?: string; + picture?: string; + customStyles?: CSSProperties; +}; + +export const Avatar = ({ displayName, picture, customStyles }: AvatarProps) => { + const classes = useStyles(); + return ( + + {displayName && extractInitials(displayName)} + + ); +}; diff --git a/plugins/scaffolder/src/components/JobStage/index.ts b/packages/core/src/components/Avatar/index.ts similarity index 93% rename from plugins/scaffolder/src/components/JobStage/index.ts rename to packages/core/src/components/Avatar/index.ts index d6d3534a88..962414634e 100644 --- a/plugins/scaffolder/src/components/JobStage/index.ts +++ b/packages/core/src/components/Avatar/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { JobStage } from './JobStage'; +export { Avatar } from './Avatar'; diff --git a/packages/backend-common/src/service/lib/metrics.test.ts b/packages/core/src/components/Avatar/util.test.ts similarity index 51% rename from packages/backend-common/src/service/lib/metrics.test.ts rename to packages/core/src/components/Avatar/util.test.ts index 9126423b7e..94de957e8e 100644 --- a/packages/backend-common/src/service/lib/metrics.test.ts +++ b/packages/core/src/components/Avatar/util.test.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { normalizePath } from './metrics'; +import { extractInitials, stringToColor } from './utils'; -describe('normalizePath', () => { - it('should normalize /path to /path', async () => { - const path = normalizePath({ url: 'http://server/path' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /path/test to /path', async () => { - const path = normalizePath({ url: 'http://server/path/test' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /api/plugin-name/test to /api/plugin-name', async () => { - const path = normalizePath({ url: 'http://server/api/plugin-name/test' }); - - expect(path).toBe('/api/plugin-name'); +describe('stringToColor', () => { + it('extract color', async () => { + expect(stringToColor('Jenny Doe')).toEqual('#7809fa'); + }); +}); + +describe('extractInitials', () => { + it('extract initials', async () => { + expect(extractInitials('Jenny Doe')).toEqual('JD'); + }); + + it('extract single letter for short name', async () => { + expect(extractInitials('Doe')).toEqual('D'); + }); + + it('limit the initials to two letters', async () => { + expect(extractInitials('John Jonathan Doe')).toEqual('JJ'); }); }); diff --git a/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts b/packages/core/src/components/Avatar/utils.ts similarity index 58% rename from plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts rename to packages/core/src/components/Avatar/utils.ts index b82ec1291a..5990a72955 100644 --- a/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts +++ b/packages/core/src/components/Avatar/utils.ts @@ -13,27 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -type buildInfo = { - // uid: timestamp - [key: string]: number; -}; -const builds = {} as buildInfo; - -export class BuildMetadataStorage { - public entityUid: string; - private builds: buildInfo; - - constructor(entityUid: string) { - this.entityUid = entityUid; - this.builds = builds; +export function stringToColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); } - - storeBuildTimestamp() { - this.builds[this.entityUid] = Date.now(); - } - - getTimestamp() { - return this.builds[this.entityUid]; + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.substr(-2); } + return color; +} + +export function extractInitials(value: string) { + return value.match(/\b\w/g)!.join('').substring(0, 2); } 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/CopyTextButton/CopyTextButton.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx index cb322e5b56..9f6cdd7e7c 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useRef, useState, MouseEventHandler } from 'react'; +import React, { useRef, useState, MouseEventHandler } from 'react'; import { IconButton, makeStyles, Tooltip } from '@material-ui/core'; import PropTypes from 'prop-types'; import CopyIcon from '@material-ui/icons/FileCopy'; @@ -56,7 +56,7 @@ const defaultProps = { tooltipText: 'Text copied to clipboard', }; -export const CopyTextButton: FC = props => { +export const CopyTextButton = (props: Props) => { const { text, tooltipDelay, tooltipText } = { ...defaultProps, ...props, diff --git a/packages/core/src/components/Drawer/Drawer.stories.tsx b/packages/core/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 0000000000..b399cfed8e --- /dev/null +++ b/packages/core/src/components/Drawer/Drawer.stories.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { + Drawer, + Button, + Typography, + makeStyles, + IconButton, + createStyles, + Theme, +} from '@material-ui/core'; +import Close from '@material-ui/icons/Close'; + +export default { + title: 'Layout/Drawer', + component: Drawer, +}; + +const useDrawerStyles = makeStyles((theme: Theme) => + createStyles({ + paper: { + width: '50%', + justifyContent: 'space-between', + padding: theme.spacing(2.5), + }, + }), +); + +const useDrawerContentStyles = makeStyles((theme: Theme) => + createStyles({ + header: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + icon: { + fontSize: 20, + }, + content: { + height: '80%', + backgroundColor: '#EEEEEE', + }, + secondaryAction: { + marginLeft: theme.spacing(2.5), + }, + }), +); + +/* Example content wrapped inside the Drawer component */ +const DrawerContent = ({ + toggleDrawer, +}: { + toggleDrawer: (isOpen: boolean) => void; +}) => { + const classes = useDrawerContentStyles(); + + return ( + <> +
+ Side Panel Title + toggleDrawer(false)} + color="inherit" + > + + +
+
+
+ + +
+ + ); +}; + +/* Default drawer can toggle open or closed. + * It can be cancelled by clicking the overlay + * or pressing the esc key. + */ +export const DefaultDrawer = () => { + const [isOpen, toggleDrawer] = useState(false); + const classes = useDrawerStyles(); + + return ( + <> + + toggleDrawer(false)} + > + + + + ); +}; + +/* Persistent drawer works like the default one - + * except that the content sits on the same level + * as the main content and you can't cancel it by + * clicking the overlay or pressing the esc key. + * + * Set the Drawer variant props: 'persistent' + */ +export const PersistentDrawer = () => { + const [isOpen, toggleDrawer] = useState(false); + const classes = useDrawerStyles(); + + return ( + <> + + toggleDrawer(false)} + > + + + + ); +}; diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 1c27ba27d3..377373a06d 100644 --- a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -20,8 +20,7 @@ import { BackstageTheme } from '@backstage/theme'; import { EmptyState } from './EmptyState'; import { CodeSnippet } from '../CodeSnippet'; -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 +const COMPONENT_YAML = `apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: example @@ -31,8 +30,7 @@ metadata: spec: type: website lifecycle: production - owner: guest -`; + owner: guest`; type Props = { annotation: string; @@ -49,10 +47,10 @@ const useStyles = makeStyles(theme => ({ export const MissingAnnotationEmptyState = ({ annotation }: Props) => { const classes = useStyles(); const description = ( - + <> The {annotation} annotation is missing. You need to add the annotation to your component if you want to enable this tool. - + ); return ( { text={COMPONENT_YAML.replace('ANNOTATION', annotation)} language="yaml" showLineNumbers - highlightedNumbers={[7, 8]} + highlightedNumbers={[6, 7]} customStyle={{ background: 'inherit', fontSize: '115%' }} />
diff --git a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx index 9dc0d25681..64722c24b0 100644 --- a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx +++ b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx @@ -16,7 +16,7 @@ import { ClickAwayListener, makeStyles, Typography } from '@material-ui/core'; import React, { - FC, + PropsWithChildren, useCallback, useEffect, useLayoutEffect, @@ -93,12 +93,12 @@ type Placement = { textWidth: number; }; -export const FeatureCalloutCircular: FC = ({ +export const FeatureCalloutCircular = ({ featureId, title, description, children, -}) => { +}: PropsWithChildren) => { const { show, hide } = useShowCallout(featureId); const portalElement = usePortal('core.callout'); const wrapperRef = useRef(null); diff --git a/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx new file mode 100644 index 0000000000..a2f86d133b --- /dev/null +++ b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { IconLinkVertical, IconLinkVerticalProps } from './IconLinkVertical'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + links: { + margin: theme.spacing(2, 0), + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'min-content', + gridGap: theme.spacing(3), + }, +})); + +type Props = { + links: IconLinkVerticalProps[]; +}; + +export const HeaderIconLinkRow = ({ links }: Props) => { + const classes = useStyles(); + return ( + + ); +}; diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx similarity index 81% rename from plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx rename to packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx index dd267dde35..7a9078c3e4 100644 --- a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx +++ b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as React from 'react'; +import React from 'react'; import classnames from 'classnames'; import { makeStyles, Link } from '@material-ui/core'; import LinkIcon from '@material-ui/icons/Link'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink } from '../Link'; export type IconLinkVerticalProps = { icon?: React.ReactNode; href?: string; disabled?: boolean; - title?: string; label: string; + action?: React.ReactNode; }; -const useIconStyles = makeStyles({ +const useIconStyles = makeStyles(theme => ({ link: { display: 'grid', justifyItems: 'center', @@ -43,12 +43,16 @@ const useIconStyles = makeStyles({ fontWeight: 600, letterSpacing: 1.2, }, -}); + linkStyle: { + color: theme.palette.secondary.main, + }, +})); export function IconLinkVertical({ icon = , href = '#', disabled = false, + action, ...props }: IconLinkVerticalProps) { const classes = useIconStyles(); @@ -58,7 +62,6 @@ export function IconLinkVertical({ {icon} @@ -67,12 +70,11 @@ export function IconLinkVertical({ ); } - // Absolute links should not be using RouterLink - if (href?.startsWith('//') || href?.includes('://')) { + if (action) { return ( - + {icon} - {props.label} + {action} ); } diff --git a/packages/core/src/components/HeaderIconLinkRow/index.ts b/packages/core/src/components/HeaderIconLinkRow/index.ts new file mode 100644 index 0000000000..82fb27cfad --- /dev/null +++ b/packages/core/src/components/HeaderIconLinkRow/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 { HeaderIconLinkRow } from './HeaderIconLinkRow'; diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx index 4e321dfa95..073fb960e1 100644 --- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx +++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { PropsWithChildren } from 'react'; import classNames from 'classnames'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; @@ -181,7 +181,7 @@ function useSmoothScroll( return setScrollTarget; } -export const HorizontalScrollGrid: FC = props => { +export const HorizontalScrollGrid = (props: PropsWithChildren) => { const { scrollStep = 100, scrollSpeed = 50, diff --git a/packages/core/src/components/Lifecycle/Lifecycle.tsx b/packages/core/src/components/Lifecycle/Lifecycle.tsx index 416df27a30..d388452cdc 100644 --- a/packages/core/src/components/Lifecycle/Lifecycle.tsx +++ b/packages/core/src/components/Lifecycle/Lifecycle.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import CSS from 'csstype'; import { makeStyles } from '@material-ui/core'; @@ -38,7 +38,7 @@ const useStyles = makeStyles({ }, }); -export const Lifecycle: FC = props => { +export const Lifecycle = (props: Props) => { const classes = useStyles(props); const { shorthand, alpha } = props; return shorthand ? ( diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx index 59a9604fa7..cd74b681db 100644 --- a/packages/core/src/components/Link/Link.tsx +++ b/packages/core/src/components/Link/Link.tsx @@ -14,18 +14,26 @@ * limitations under the License. */ -import React, { ComponentProps } from 'react'; -import { Link as MaterialLink } from '@material-ui/core'; -import { Link as RouterLink } from 'react-router-dom'; +import { + Link as MaterialLink, + LinkProps as MaterialLinkProps, +} from '@material-ui/core'; +import React, { ElementType } from 'react'; +import { + Link as RouterLink, + LinkProps as RouterLinkProps, +} from 'react-router-dom'; -type Props = ComponentProps & - ComponentProps & { component?: React.FC }; +export type LinkProps = MaterialLinkProps & + RouterLinkProps & { + component?: ElementType; + }; /** * Thin wrapper on top of material-ui's Link component * Makes the Link to utilise react-router */ -export const Link = React.forwardRef((props, ref) => { +export const Link = React.forwardRef((props, ref) => { const to = String(props.to); return /^https?:\/\//.test(to) ? ( // External links diff --git a/packages/core/src/components/Link/index.ts b/packages/core/src/components/Link/index.ts index 18e990181c..9be779feb7 100644 --- a/packages/core/src/components/Link/index.ts +++ b/packages/core/src/components/Link/index.ts @@ -15,3 +15,4 @@ */ export { Link } from './Link'; +export type { LinkProps } from './Link'; diff --git a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index cc7d99660b..cc1df3b529 100644 --- a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -22,7 +22,7 @@ import { Typography, Theme, } from '@material-ui/core'; -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { PendingAuthRequest } from '@backstage/core-api'; const useItemStyles = makeStyles(theme => ({ @@ -37,7 +37,7 @@ type RowProps = { setBusy: (busy: boolean) => void; }; -const LoginRequestListItem: FC = ({ request, busy, setBusy }) => { +const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => { const classes = useItemStyles(); const [error, setError] = useState(); diff --git a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index 07078c3fa2..06b53332dc 100644 --- a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -24,7 +24,7 @@ import { Theme, Button, } from '@material-ui/core'; -import React, { FC, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import LoginRequestListItem from './LoginRequestListItem'; import { useApi, oauthRequestApiRef } from '@backstage/core-api'; @@ -41,9 +41,7 @@ const useStyles = makeStyles(theme => ({ }, })); -type OAuthRequestDialogProps = {}; - -export const OAuthRequestDialog: FC = () => { +export const OAuthRequestDialog = () => { const classes = useStyles(); const [busy, setBusy] = useState(false); const oauthRequestApi = useApi(oauthRequestApiRef); diff --git a/packages/core/src/components/OverflowTooltip/OverflowTooltip.stories.tsx b/packages/core/src/components/OverflowTooltip/OverflowTooltip.stories.tsx new file mode 100644 index 0000000000..e0cbe7c814 --- /dev/null +++ b/packages/core/src/components/OverflowTooltip/OverflowTooltip.stories.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Box } from '@material-ui/core'; +import React from 'react'; +import { OverflowTooltip } from './OverflowTooltip'; + +export default { + title: 'Data Display/OverflowTooltip', + component: OverflowTooltip, +}; + +const text = + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.'; + +export const Default = () => ( + + + +); + +export const MultiLine = () => ( + + + +); + +export const DifferentTitle = () => ( + + + +); diff --git a/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx b/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx new file mode 100644 index 0000000000..faf064f9f4 --- /dev/null +++ b/packages/core/src/components/OverflowTooltip/OverflowTooltip.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 { makeStyles, Tooltip, TooltipProps } from '@material-ui/core'; +import React, { useState } from 'react'; +import TextTruncate, { TextTruncateProps } from 'react-text-truncate'; + +type Props = { + text: TextTruncateProps['text']; + line?: TextTruncateProps['line']; + element?: TextTruncateProps['element']; + title?: TooltipProps['title']; + placement?: TooltipProps['placement']; +}; + +const useStyles = makeStyles({ + container: { + overflow: 'visible !important', + }, +}); + +export const OverflowTooltip = (props: Props) => { + const [hover, setHover] = useState(false); + const classes = useStyles(); + + const handleToggled = (truncated: boolean) => { + setHover(truncated); + }; + + return ( + + + + ); +}; diff --git a/packages/core/src/components/OverflowTooltip/index.ts b/packages/core/src/components/OverflowTooltip/index.ts new file mode 100644 index 0000000000..fe51e8267f --- /dev/null +++ b/packages/core/src/components/OverflowTooltip/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 { OverflowTooltip } from './OverflowTooltip'; diff --git a/packages/core/src/components/Progress/Progress.tsx b/packages/core/src/components/Progress/Progress.tsx index 80f4f38cc9..aacdf8821b 100644 --- a/packages/core/src/components/Progress/Progress.tsx +++ b/packages/core/src/components/Progress/Progress.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { useState, useEffect, PropsWithChildren } from 'react'; import { LinearProgress, LinearProgressProps } from '@material-ui/core'; -export const Progress: FC = props => { +export const Progress = (props: PropsWithChildren) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { 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/Gauge.tsx b/packages/core/src/components/ProgressBars/Gauge.tsx index 4c339bf8e1..ca6a3a66ab 100644 --- a/packages/core/src/components/ProgressBars/Gauge.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.tsx @@ -17,7 +17,7 @@ import { makeStyles, useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { Circle } from 'rc-progress'; -import React, { FC } from 'react'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: { @@ -77,7 +77,7 @@ export function getProgressColor( return palette.status.ok; } -export const Gauge: FC = props => { +export const Gauge = (props: Props) => { const classes = useStyles(props); const theme = useTheme(); const { value, fractional, inverse, unit, max } = { diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index 4eb4b2e075..2154619b01 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -14,16 +14,16 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; -import { InfoCard } from '../../layout/InfoCard'; +import { InfoCard, InfoCardVariants } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; import { Gauge } from './Gauge'; type Props = { title: string; subheader?: string; - variant?: string; + variant?: InfoCardVariants; /** Progress in % specified as decimal, e.g. "0.23" */ progress: number; deepLink?: BottomLinkProps; @@ -36,7 +36,7 @@ const useStyles = makeStyles({ }, }); -export const GaugeCard: FC = props => { +export const GaugeCard = (props: Props) => { const classes = useStyles(props); const { title, subheader, progress, deepLink, variant } = props; 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/ProgressBars/LinearGauge.tsx b/packages/core/src/components/ProgressBars/LinearGauge.tsx index a6aea59f19..9bb7b34c09 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Tooltip, useTheme } from '@material-ui/core'; // @ts-ignore import { Line } from 'rc-progress'; @@ -28,7 +28,7 @@ type Props = { value: number; }; -export const LinearGauge: FC = ({ value }) => { +export const LinearGauge = ({ value }: Props) => { const theme = useTheme(); if (isNaN(value)) { return null; diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx index 75fc1e5e62..ff65534843 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepper.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx @@ -16,9 +16,9 @@ import React, { Children, isValidElement, - FC, useState, useEffect, + PropsWithChildren, } from 'react'; import { Stepper as MuiStepper } from '@material-ui/core'; @@ -47,12 +47,12 @@ export interface StepperProps { activeStep?: number; } -export const SimpleStepper: FC = ({ +export const SimpleStepper = ({ children, elevated, onStepChange, activeStep = 0, -}) => { +}: PropsWithChildren) => { const [stepIndex, setStepIndex] = useState(activeStep); const [stepHistory, setStepHistory] = useState([0]); diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx index c8cddb375d..a49e20913c 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useContext, FC, ReactNode } from 'react'; +import React, { useContext, ReactNode, PropsWithChildren } from 'react'; import { Button, makeStyles } from '@material-ui/core'; import { StepActions } from './SimpleStepperStep'; import { VerticalStepperContext } from './SimpleStepper'; @@ -27,20 +27,33 @@ const useStyles = makeStyles(theme => ({ }, })); -export const RestartBtn: FC<{ +interface CommonBtnProps { text?: string; handleClick?: () => void; stepIndex: number; -}> = ({ text, handleClick }) => ( - -); -const NextBtn: FC<{ - text?: string; - handleClick?: () => void; +} +interface RestartBtnProps extends CommonBtnProps {} + +interface NextBtnProps extends CommonBtnProps { disabled?: boolean; last?: boolean; stepIndex: number; -}> = ({ text, handleClick, disabled, last, stepIndex }) => ( +} +interface BackBtnProps extends CommonBtnProps { + disabled?: boolean; + stepIndex: number; +} +export const RestartBtn = ({ text, handleClick }: RestartBtnProps) => ( + +); + +const NextBtn = ({ + text, + handleClick, + disabled, + last, + stepIndex, +}: NextBtnProps) => (
} + /> + +
+ ), + }, + ]} + /> + } + /> + , + { routeEntries: [route] }, + ); + + it('works for nested content', async () => { + const rendered = await renderRoute('/some-other-path/nested'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).toBeInTheDocument(); + }); + + it('works for non-nested content', async () => { + const rendered = await renderRoute('/some-other-path/'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).not.toBeInTheDocument(); + }); + }); + + it('shows only one tab contents at a time', async () => { + const rendered = await renderInTestApp( + , + { routeEntries: ['/some-other-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + it('redirects to the top level when no route is matching the url', async () => { + const rendered = await renderInTestApp( + , + { routeEntries: ['/non-existing-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + + expect( + rendered.queryByText('tabbed-test-content-2'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx new file mode 100644 index 0000000000..b17c8afb3b --- /dev/null +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.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, { useMemo } from 'react'; +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( + subRoutes: SubRoute[], +): { index: number; route: SubRoute; element: JSX.Element } { + const params = useParams(); + + const routes = subRoutes.map(({ path, children }) => ({ + caseSensitive: false, + path: `${path}/*`, + element: children, + })); + + const element = useRoutes(routes) ?? subRoutes[0].children; + + const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? []; + const foundIndex = matchedRoute + ? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path) + : 0; + + return { + index: foundIndex === -1 ? 0 : foundIndex, + element, + route: subRoutes[foundIndex] ?? subRoutes[0], + }; +} + +export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => { + const navigate = useNavigate(); + const { index, route, element } = useSelectedSubRoute(routes); + const headerTabs = useMemo( + () => routes.map(t => ({ id: t.path, label: t.title })), + [routes], + ); + + 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[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, '')); + + return ( + <> + + + + {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/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts b/packages/core/src/components/TabbedLayout/types.ts similarity index 87% rename from plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts rename to packages/core/src/components/TabbedLayout/types.ts index 1b7d35c0a2..29ade88dc0 100644 --- a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts +++ b/packages/core/src/components/TabbedLayout/types.ts @@ -14,4 +14,8 @@ * limitations under the License. */ -export { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; +export type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; diff --git a/packages/core/src/components/Table/SubvalueCell.tsx b/packages/core/src/components/Table/SubvalueCell.tsx index 139cdee588..944195a2db 100644 --- a/packages/core/src/components/Table/SubvalueCell.tsx +++ b/packages/core/src/components/Table/SubvalueCell.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles } from '@material-ui/core'; @@ -33,7 +33,7 @@ type SubvalueCellProps = { subvalue: React.ReactNode; }; -export const SubvalueCell: FC = ({ value, subvalue }) => { +export const SubvalueCell = ({ value, subvalue }: SubvalueCellProps) => { const classes = useSubvalueCellStyles(); return ( diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 4be85e494f..64ceb761f8 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -14,8 +14,10 @@ * limitations under the License. */ +import { makeStyles } from '@material-ui/core'; import React from 'react'; -import { Table, SubvalueCell, TableColumn } from './'; +import { Link } from '../Link'; +import { SubvalueCell, Table, TableColumn } from './'; import { TableFilter } from './Table'; export default { @@ -23,7 +25,16 @@ export default { component: Table, }; -const containerStyle = { width: 850 }; +const useStyles = makeStyles(theme => ({ + container: { + width: 850, + }, + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const data: Array<{}> = []; @@ -43,6 +54,7 @@ const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const testData10 = generateTestData(10); export const DefaultTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -66,7 +78,7 @@ export const DefaultTable = () => { ]; return ( -
+
{ ); }; -export const SubtitleTable = () => { +export const EmptyTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -101,7 +114,49 @@ export const SubtitleTable = () => { ]; return ( -
+
+
+ No data was added yet,  + learn how to add data. + + } + title="Backstage Table" + /> + + ); +}; + +export const SubtitleTable = () => { + const classes = useStyles(); + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
{ }; export const HiddenSearchTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -137,7 +193,7 @@ export const HiddenSearchTable = () => { ]; return ( -
+
{ }; export const SubvalueTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -181,13 +238,14 @@ export const SubvalueTable = () => { ]; return ( -
+
); }; export const DenseTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -211,7 +269,7 @@ export const DenseTable = () => { ]; return ( -
+
{ }; export const FilterTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -261,7 +320,7 @@ export const FilterTable = () => { ]; return ( -
+
', () => { ); expect(rendered.getByText('subtitle')).toBeInTheDocument(); }); + + it('renders custom empty component if empty', async () => { + const rendered = await renderInTestApp( +
EMPTY} + columns={minProps.columns} + data={[]} + />, + ); + expect(rendered.getByText('EMPTY')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 852f4a1547..3892d82bb3 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -40,13 +40,16 @@ import ViewColumn from '@material-ui/icons/ViewColumn'; import { isEqual, transform } from 'lodash'; import MTable, { Column, + Icons, MaterialTableProps, + MTableBody, MTableHeader, MTableToolbar, Options, } from 'material-table'; import React, { forwardRef, + ReactNode, useCallback, useEffect, useRef, @@ -56,58 +59,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? @@ -231,6 +204,7 @@ export interface TableProps subtitle?: string; filters?: TableFilter[]; initialState?: TableState; + emptyContent?: ReactNode; onStateChange?: (state: TableState) => any; } @@ -241,6 +215,7 @@ export function Table({ subtitle, filters, initialState, + emptyContent, onStateChange, ...props }: TableProps) { @@ -452,6 +427,23 @@ export function Table({ ], ); + const Body = useCallback( + bodyProps => { + if (emptyContent && data.length === 0) { + return ( + + + + + + ); + } + + return ; + }, + [data, emptyContent, columns], + ); + return (
{filtersOpen && data && filters?.length && ( @@ -467,6 +459,7 @@ export function Table({ ), Toolbar, + Body, }} options={{ ...defaultOptions, ...options }} columns={MTColumns} diff --git a/packages/core/src/components/Tabs/TabBar.tsx b/packages/core/src/components/Tabs/TabBar.tsx index 379d60668b..c874200161 100644 --- a/packages/core/src/components/Tabs/TabBar.tsx +++ b/packages/core/src/components/Tabs/TabBar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { PropsWithChildren } from 'react'; import { Tabs, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; @@ -40,7 +40,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const StyledTabs: FC = props => { +export const StyledTabs = (props: PropsWithChildren) => { const classes = useStyles(props); return ( = props => { +export const TabPanel = (props: PropsWithChildren) => { const { children, value, index, ...other } = props; return ( diff --git a/packages/core/src/components/Tabs/Tabs.tsx b/packages/core/src/components/Tabs/Tabs.tsx index 336ed2b93b..f34afc1350 100644 --- a/packages/core/src/components/Tabs/Tabs.tsx +++ b/packages/core/src/components/Tabs/Tabs.tsx @@ -14,13 +14,7 @@ * limitations under the License. */ -import React, { - FC, - useRef, - useEffect, - MutableRefObject, - useState, -} from 'react'; +import React, { useRef, useEffect, MutableRefObject, useState } from 'react'; import { BackstageTheme } from '@backstage/theme'; import { AppBar } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; @@ -64,7 +58,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const Tabs: FC = ({ tabs }) => { +export const Tabs = ({ tabs }: TabsProps) => { const classes = useStyles(); const [value, setValue] = useState([0, 0]); // [selectedChunkedNavIndex, selectedIndex] const [navIndex, setNavIndex] = useState(0); diff --git a/packages/core/src/components/TrendLine/TrendLine.stories.tsx b/packages/core/src/components/TrendLine/TrendLine.stories.tsx index 5d2972095a..6a5f8564f7 100644 --- a/packages/core/src/components/TrendLine/TrendLine.stories.tsx +++ b/packages/core/src/components/TrendLine/TrendLine.stories.tsx @@ -55,6 +55,37 @@ const data = [ month: , year: , }, + { + stock: 'E', + day: ( + + ), + week: ( + + ), + month: ( + + ), + year: ( + + ), + }, ]; const columns = [ @@ -98,3 +129,13 @@ export const TrendingDown = () => (
); + +export const TrendingCustomColor = () => ( +
+ +
+); diff --git a/packages/core/src/components/TrendLine/TrendLine.tsx b/packages/core/src/components/TrendLine/TrendLine.tsx index 6f2f70d38a..84024634d3 100644 --- a/packages/core/src/components/TrendLine/TrendLine.tsx +++ b/packages/core/src/components/TrendLine/TrendLine.tsx @@ -14,8 +14,13 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import { Sparklines, SparklinesLine, SparklinesProps } from 'react-sparklines'; +import React from 'react'; +import { + Sparklines, + SparklinesLine, + SparklinesLineProps, + SparklinesProps, +} from 'react-sparklines'; import { useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; @@ -27,14 +32,17 @@ function color(data: number[], theme: BackstageTheme): string | undefined { return theme.palette.status.error; } -export const TrendLine: FC = props => { +export const TrendLine = ( + props: SparklinesProps & + Pick & { title?: string }, +) => { const theme = useTheme(); if (!props.data) return null; return ( {props.title && {props.title}} - + ); }; 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 bcc56debea..58d0a8f569 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -15,25 +15,30 @@ */ export * from './AlertDisplay'; +export * from './Avatar'; export * from './Button'; 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 './OverflowTooltip'; export * from './Progress'; export * from './ProgressBars'; +export * from './Select'; 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'; diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index a77a0eb8a4..6408790b8d 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -15,3 +15,9 @@ */ export { useQueryParamState } from './useQueryParamState'; +export { useSupportConfig } from './useSupportConfig'; +export type { + SupportConfig, + SupportItem, + SupportItemLink, +} from './useSupportConfig'; diff --git a/packages/core/src/hooks/useSupportConfig.ts b/packages/core/src/hooks/useSupportConfig.ts new file mode 100644 index 0000000000..e77c64dd85 --- /dev/null +++ b/packages/core/src/hooks/useSupportConfig.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi, configApiRef } from '@backstage/core-api'; + +export type SupportItemLink = { + url: string; + title: string; +}; + +export type SupportItem = { + title: string; + icon?: string; + links: SupportItemLink[]; +}; + +export type SupportConfig = { + url: string; + items: SupportItem[]; +}; + +const DEFAULT_SUPPORT_CONFIG: SupportConfig = { + url: 'https://github.com/backstage/backstage/issues', + items: [ + { + title: 'Support Not Configured', + icon: 'warning', + links: [ + { + // TODO: Update to dedicated support page on backstage.io/docs + title: 'Add `app.support` config key', + url: + 'https://github.com/andrewthauer/backstage/blob/master/app-config.yaml', + }, + ], + }, + ], +}; + +export function useSupportConfig(): SupportConfig { + const config = useApi(configApiRef); + const supportConfig = config.getOptionalConfig('app.support'); + + if (!supportConfig) { + return DEFAULT_SUPPORT_CONFIG; + } + + return { + url: supportConfig.getString('url'), + items: supportConfig.getConfigArray('items').flatMap(itemConf => ({ + title: itemConf.getString('title'), + icon: itemConf.getOptionalString('icon'), + links: (itemConf.getOptionalConfigArray('links') ?? []).flatMap( + linkConf => ({ + url: linkConf.getString('url'), + title: linkConf.getString('title'), + }), + ), + })), + }; +} diff --git a/packages/core/src/layout/BottomLink/BottomLink.tsx b/packages/core/src/layout/BottomLink/BottomLink.tsx index e768ffd9e4..f412dee674 100644 --- a/packages/core/src/layout/BottomLink/BottomLink.tsx +++ b/packages/core/src/layout/BottomLink/BottomLink.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { ListItem, ListItemIcon, @@ -47,7 +47,7 @@ export type BottomLinkProps = { onClick?: (event: React.MouseEvent) => void; }; -export const BottomLink: FC = ({ link, title, onClick }) => { +export const BottomLink = ({ link, title, onClick }: BottomLinkProps) => { const classes = useStyles(); return ( diff --git a/packages/core/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 0000000000..6f0ef0cb69 --- /dev/null +++ b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Box, List, ListItem, Popover, Typography } from '@material-ui/core'; +import ExpandLessIcon from '@material-ui/icons/ExpandLess'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import React, { Fragment } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { Breadcrumbs } from '.'; +import { Header, Page } from '..'; +import { Link } from '../../components/Link'; + +export default { + title: 'Layout/Breadcrumbs', + component: Breadcrumbs, +}; + +export const InHeader = () => ( + +

Standard breadcrumbs

+

+ Underlined pages are links. This should show a hierarchical relationship. +

+ + +
+ + +); + +export const OutsideOfHeader = () => { + const [anchorEl, setAnchorEl] = React.useState( + null, + ); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + return ( + +

+ It might be the case that you want to keep your breadcrumbs outside of + the header. In that case, they should be positioned above the title of + the page. +

+ +

Standard breadcrumbs

+

+ Underlined pages are links. This should show a hierarchical + relationship. +

+ + + + + General Page + Second Page + Current page + + +

Hidden breadcrumbs

+

+ Use this when you have more than three breadcrumbs. When user clicks on + ellipses, expand the breadcrumbs out. +

+ + + General Page + Second Page + Third Page + Fourth Page + Current page + + +

Layered breadcrumbs

+

+ Use this when you want to show alternative breadcrumbs on the same + hierarchical level. +

+ + + + General Page + + + Second Page + {open ? : } + + + Current page + + + + + Parallel second page + + + Another parallel second page + + + Yet another, parallel second page + + + + +
+ ); +}; diff --git a/packages/core/src/layout/Breadcrumbs/Breadcrumbs.test.tsx b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.test.tsx new file mode 100644 index 0000000000..0a490afb76 --- /dev/null +++ b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.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 { renderInTestApp } from '@backstage/test-utils'; +import { Typography } from '@material-ui/core'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { Link } from '../..'; +import { Breadcrumbs } from './Breadcrumbs'; + +describe('', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + General Page + Current Page + , + ); + expect(rendered.getByLabelText('breadcrumb')).toBeVisible(); + expect(rendered.getByText('General Page')).toBeVisible(); + expect(rendered.getByText('Current Page')).toBeVisible(); + }); + + it('should render hidden breadcrumbs', async () => { + const rendered = await renderInTestApp( + + General Page + Second Page + Third Page + Fourth Page + Current page + , + ); + expect(rendered.getByText('...')).toBeVisible(); + expect(rendered.queryByText('Third Page')).not.toBeInTheDocument(); + expect(rendered.queryByText('Fourth Page')).not.toBeInTheDocument(); + fireEvent.click(rendered.getByText('...')); + expect(rendered.getByText('Third Page')).toBeVisible(); + expect(rendered.getByText('Fourth Page')).toBeVisible(); + }); +}); diff --git a/packages/core/src/layout/Breadcrumbs/Breadcrumbs.tsx b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..d8cf88497f --- /dev/null +++ b/packages/core/src/layout/Breadcrumbs/Breadcrumbs.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 { + Box, + Breadcrumbs as MaterialBreadcrumbs, + List, + ListItem, + Popover, + Typography, + withStyles, +} from '@material-ui/core'; +import React, { ComponentProps, Fragment } from 'react'; + +type Props = ComponentProps; + +const ClickableText = withStyles({ + root: { + textDecoration: 'underline', + cursor: 'pointer', + }, +})(Typography); + +const StyledBox = withStyles({ + root: { + textDecoration: 'underline', + color: 'inherit', + }, +})(Box); + +export const Breadcrumbs = ({ children, ...props }: Props) => { + const [anchorEl, setAnchorEl] = React.useState( + null, + ); + + const childrenArray = React.Children.toArray(children); + + const [firstPage, secondPage, ...expandablePages] = childrenArray; + const currentPage = expandablePages.length + ? expandablePages.pop() + : childrenArray[childrenArray.length - 1]; + const hasHiddenBreadcrumbs = childrenArray.length > 3; + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + return ( + + + {childrenArray.length > 1 && {firstPage}} + {childrenArray.length > 2 && {secondPage}} + {hasHiddenBreadcrumbs && ( + ... + )} + {currentPage} + + + + {expandablePages.map(pageLink => ( + + {pageLink} + + ))} + + + + ); +}; diff --git a/plugins/sentry/src/components/SentryPluginPage/index.ts b/packages/core/src/layout/Breadcrumbs/index.ts similarity index 92% rename from plugins/sentry/src/components/SentryPluginPage/index.ts rename to packages/core/src/layout/Breadcrumbs/index.ts index 67b34db517..6c5c2539df 100644 --- a/plugins/sentry/src/components/SentryPluginPage/index.ts +++ b/packages/core/src/layout/Breadcrumbs/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './SentryPluginPage'; +export { Breadcrumbs } from './Breadcrumbs'; diff --git a/packages/core/src/layout/Content/Content.tsx b/packages/core/src/layout/Content/Content.tsx index 6a5b2c241a..bf305deaea 100644 --- a/packages/core/src/layout/Content/Content.tsx +++ b/packages/core/src/layout/Content/Content.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { PropsWithChildren } from 'react'; import classNames from 'classnames'; import { Theme, makeStyles } from '@material-ui/core'; @@ -42,13 +42,13 @@ type Props = { className?: string; }; -export const Content: FC = ({ +export const Content = ({ className, stretch, noPadding, children, ...props -}) => { +}: PropsWithChildren) => { const classes = useStyles(); return (
= ({ +const DefaultTitle = ({ title = 'Unknown page', className, -}) => ( +}: DefaultTitleProps) => ( {title} @@ -78,13 +78,13 @@ type ContentHeaderProps = { textAlign?: 'left' | 'right' | 'center'; }; -export const ContentHeader: FC = ({ +export const ContentHeader = ({ description, title, titleComponent: TitleComponent = undefined, children, textAlign = 'left', -}) => { +}: PropsWithChildren) => { const classes = useStyles({ textAlign })(); const renderedTitle = TitleComponent ? ( 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/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx index 776ed41614..466c2f46b6 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx @@ -20,6 +20,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; import { MicDrop } from './MicDrop'; import { useNavigate } from 'react-router'; +import { useSupportConfig } from '../../hooks'; interface IErrorPageProps { status: string; @@ -53,6 +54,7 @@ export const ErrorPage = ({ }: IErrorPageProps) => { const classes = useStyles(); const navigate = useNavigate(); + const support = useSupportConfig(); return ( @@ -71,13 +73,11 @@ export const ErrorPage = ({ navigate(-1)}> Go back - ... or if you think this is a bug, please file an{' '} - - issue. - + ... or please{' '} + + contact support + {' '} + if you think this is a bug. diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index ec4a3db6e2..73fa3066ea 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -14,17 +14,12 @@ * limitations under the License. */ -import React, { ReactNode, CSSProperties, FC } from 'react'; +import React, { ReactNode, CSSProperties, PropsWithChildren } from 'react'; import { Helmet } from 'react-helmet'; -import { - Link, - Typography, - Tooltip, - makeStyles, - Breadcrumbs, -} from '@material-ui/core'; -import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { Typography, Tooltip, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; +import { Breadcrumbs } from '..'; +import { Link } from '../../components'; const useStyles = makeStyles(theme => ({ header: { @@ -121,12 +116,12 @@ type SubtitleFragmentProps = { subtitle?: Props['subtitle']; }; -const TypeFragment: FC = ({ +const TypeFragment = ({ type, typeLink, classes, pageTitle, -}) => { +}: TypeFragmentProps) => { if (!type) { return null; } @@ -136,24 +131,14 @@ const TypeFragment: FC = ({ } return ( - } - className={classes.breadcrumb} - > - - {type} - - {pageTitle} + + {type} + {pageTitle} ); }; -const TitleFragment: FC = ({ - pageTitle, - classes, - tooltip, -}) => { +const TitleFragment = ({ pageTitle, classes, tooltip }: TitleFragmentProps) => { const FinalTitle = ( {pageTitle} @@ -171,7 +156,7 @@ const TitleFragment: FC = ({ ); }; -const SubtitleFragment: FC = ({ classes, subtitle }) => { +const SubtitleFragment = ({ classes, subtitle }: SubtitleFragmentProps) => { if (!subtitle) { return null; } @@ -187,7 +172,7 @@ const SubtitleFragment: FC = ({ classes, subtitle }) => { ); }; -export const Header: FC = ({ +export const Header = ({ children, pageTitleOverride, style, @@ -196,7 +181,7 @@ export const Header: FC = ({ tooltip, type, typeLink, -}) => { +}: PropsWithChildren) => { const classes = useStyles(); const documentTitle = pageTitleOverride || title; const pageTitle = title || pageTitleOverride; diff --git a/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.tsx b/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.tsx index a1a4eb6566..6f0b337b54 100644 --- a/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.tsx +++ b/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { Fragment, ReactElement, FC, ComponentType } from 'react'; +import React, { Fragment, ReactElement, ComponentType } from 'react'; import { IconButton, List, @@ -35,14 +35,14 @@ type ActionItemProps = { WrapperComponent?: ComponentType; }; -const ActionItem: FC = ({ +const ActionItem = ({ label, secondaryLabel, icon, disabled = false, onClick, WrapperComponent = React.Fragment, -}) => { +}: ActionItemProps) => { return ( = ({ - actionItems, -}) => { +export const HeaderActionMenu = ({ actionItems }: HeaderActionMenuProps) => { const [open, setOpen] = React.useState(false); const anchorElRef = React.useRef(null); diff --git a/packages/core/src/layout/HeaderLabel/HeaderLabel.tsx b/packages/core/src/layout/HeaderLabel/HeaderLabel.tsx index d4457a6fba..914be06cf2 100644 --- a/packages/core/src/layout/HeaderLabel/HeaderLabel.tsx +++ b/packages/core/src/layout/HeaderLabel/HeaderLabel.tsx @@ -15,7 +15,7 @@ */ import { Link, makeStyles, Typography } from '@material-ui/core'; -import React, { FC } from 'react'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: { @@ -45,10 +45,9 @@ type HeaderLabelContentProps = { className: string; }; -const HeaderLabelContent: FC = ({ - value, - className, -}) => {value}; +const HeaderLabelContent = ({ value, className }: HeaderLabelContentProps) => ( + {value} +); type HeaderLabelProps = { label: string; @@ -56,7 +55,7 @@ type HeaderLabelProps = { url?: string; }; -export const HeaderLabel: FC = ({ label, value, url }) => { +export const HeaderLabel = ({ label, value, url }: HeaderLabelProps) => { const classes = useStyles(); const content = ( ({ tabsWrapper: { @@ -43,11 +43,16 @@ export type Tab = { label: string; }; -export const HeaderTabs: React.FC<{ +type HeaderTabsProps = { tabs: Tab[]; onChange?: (index: number) => void; selectedIndex?: number; -}> = ({ tabs, onChange, selectedIndex }) => { +}; +export const HeaderTabs = ({ + tabs, + onChange, + selectedIndex, +}: HeaderTabsProps) => { const [selectedTab, setSelectedTab] = useState(selectedIndex ?? 0); const styles = useStyles(); @@ -76,7 +81,7 @@ export const HeaderTabs: React.FC<{ 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..cf06a5f0a1 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -76,32 +76,19 @@ 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, }, }, }; +export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem'; + /** * InfoCard is used to display a paper-styled block on the screen, similar to a panel. * @@ -126,7 +113,7 @@ type Props = { divider?: boolean; deepLink?: BottomLinkProps; slackChannel?: string; - variant?: string; + variant?: InfoCardVariants; style?: object; cardStyle?: object; children?: ReactNode; @@ -167,12 +154,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/InfoCard/index.ts b/packages/core/src/layout/InfoCard/index.ts index d77fcf93c4..35829662d0 100644 --- a/packages/core/src/layout/InfoCard/index.ts +++ b/packages/core/src/layout/InfoCard/index.ts @@ -15,3 +15,4 @@ */ export { InfoCard } from './InfoCard'; +export type { InfoCardVariants } from './InfoCard'; diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 75031a0765..eea78562e8 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', @@ -29,7 +30,7 @@ export const Default = () => ( title="Item Card" description="This is the description of an Item Card" label="Button" - type="Pretitle" + subtitle="Pretitle" onClick={() => {}} /> @@ -38,7 +39,7 @@ export const Default = () => ( title="Item Card" description="This is the description of an Item Card" label="Button" - type="Pretitle" + subtitle="Pretitle" onClick={() => {}} /> @@ -63,5 +64,35 @@ export const Tags = () => ( label="Button" /> + + + ); + +export const Link = () => ( + + + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.test.tsx b/packages/core/src/layout/ItemCard/ItemCard.test.tsx index 6afeb16c5b..30cf95fd85 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.test.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.test.tsx @@ -14,25 +14,35 @@ * limitations under the License. */ -import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; import { ItemCard } from './ItemCard'; const minProps = { description: 'This is the description of an Item Card', label: 'Button', title: 'Item Card', - type: 'Pretitle', }; describe('', () => { it('renders default without exploding', async () => { - const { description, label, title, type } = minProps; + const { description, label, title } = minProps; const { getByText } = await renderInTestApp(); expect(getByText(description)).toBeInTheDocument(); expect(getByText(title)).toBeInTheDocument(); expect(getByText(label)).toBeInTheDocument(); - expect(getByText(type)).toBeInTheDocument(); + }); + + it('renders with subtitle without exploding', async () => { + const { description, label, title } = minProps; + const subtitle = 'Pretitle'; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(description)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(label)).toBeInTheDocument(); + expect(getByText(subtitle)).toBeInTheDocument(); }); it('renders with tags without exploding', async () => { diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index a17cbed563..9e16def5d3 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 React, { FC } from 'react'; -import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; +import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core'; +import clsx from 'clsx'; +import React, { ReactNode } from 'react'; +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', @@ -40,37 +45,59 @@ type ItemCardProps = { description?: string; tags?: string[]; title: string; + /** @deprecated Use subtitle instead */ type?: string; + subtitle?: ReactNode; label: string; onClick?: () => void; + href?: string; }; -export const ItemCard: FC = ({ + +export const ItemCard = ({ description, tags, title, type, + subtitle, label, onClick, -}) => { + href, +}: ItemCardProps) => { const classes = useStyles(); return (
- {type ?? {type}} + {(subtitle || type) && ( + {subtitle ?? type} + )} {title}
{tags?.map((tag, i) => ( ))} - + 0 && classes.withTags, + )} + > {description}
- + {!href && ( + + )} + {href && ( + + )}
diff --git a/packages/core/src/layout/Page/Page.stories.tsx b/packages/core/src/layout/Page/Page.stories.tsx index 1fdaf8eced..79d9fa5d72 100644 --- a/packages/core/src/layout/Page/Page.stories.tsx +++ b/packages/core/src/layout/Page/Page.stories.tsx @@ -26,6 +26,7 @@ import { InfoCard, Page, } from '../'; +import { createApp } from '../../api-wrappers'; import { GaugeCard, StatusOK, @@ -192,46 +193,53 @@ const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => ( ); +const app = createApp({ configLoader: async () => [] }); +const AppProvider = app.getProvider(); + export const PluginWithData = () => { const [selectedTab, setSelectedTab] = useState(2); return ( - -
- - - setSelectedTab(index)} - tabs={tabs.map(({ label }, index) => ({ - id: index.toString(), - label, - }))} - /> - - - - - -
-
+ + +
+ + + setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + + + + +
+
+
); }; export const PluginWithTable = () => { return ( -
- - - - -
{emptyContent}
- - - + +
+ + + + +
+ + + + ); }; diff --git a/packages/core/src/layout/Page/Page.tsx b/packages/core/src/layout/Page/Page.tsx index a14f772334..3f04948356 100644 --- a/packages/core/src/layout/Page/Page.tsx +++ b/packages/core/src/layout/Page/Page.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { PropsWithChildren } from 'react'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, ThemeProvider } from '@material-ui/core'; @@ -33,7 +33,7 @@ type Props = { themeId: string; }; -export const Page: FC = ({ themeId, children }) => { +export const Page = ({ themeId, children }: PropsWithChildren) => { const classes = useStyles(); return ( (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, @@ -71,11 +76,11 @@ type Props = { closeDelayMs?: number; }; -export const Sidebar: FC = ({ +export const Sidebar = ({ openDelayMs = sidebarConfig.defaultOpenDelayMs, closeDelayMs = sidebarConfig.defaultCloseDelayMs, children, -}) => { +}: PropsWithChildren) => { const classes = useStyles(); const [state, setState] = useState(State.Closed); const hoverTimerRef = useRef(); diff --git a/packages/core/src/layout/Sidebar/Intro.tsx b/packages/core/src/layout/Sidebar/Intro.tsx index 912b343244..d0af0c3633 100644 --- a/packages/core/src/layout/Sidebar/Intro.tsx +++ b/packages/core/src/layout/Sidebar/Intro.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useContext, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useLocalStorage } from 'react-use'; import { Link, Typography, makeStyles, Collapse } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; @@ -74,7 +74,7 @@ type IntroCardProps = { onClose: () => void; }; -export const IntroCard: FC = props => { +export const IntroCard = (props: IntroCardProps) => { const classes = useStyles(); const { text, onClose } = props; const handleClose = () => onClose(); @@ -109,7 +109,7 @@ type SidebarIntroCardProps = { onDismiss: () => void; }; -const SidebarIntroCard: FC = props => { +const SidebarIntroCard = (props: SidebarIntroCardProps) => { const { text, onDismiss } = props; const [collapsing, setCollapsing] = useState(false); const startDismissing = () => { @@ -127,7 +127,7 @@ Keep an eye out for the little star icon (⭐) next to the plugin name and give const recentlyViewedIntroText = 'And your recently viewed plugins will pop up here!'; -export const SidebarIntro: FC = () => { +export const SidebarIntro = () => { const { isOpen } = useContext(SidebarContext); const defaultValue = { starredItemsDismissed: false, 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..e41a45a225 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,104 @@ 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/Sidebar/Page.tsx b/packages/core/src/layout/Sidebar/Page.tsx index 0c6f4a526d..25717cb053 100644 --- a/packages/core/src/layout/Sidebar/Page.tsx +++ b/packages/core/src/layout/Sidebar/Page.tsx @@ -15,7 +15,12 @@ */ import { makeStyles } from '@material-ui/core'; -import React, { createContext, FC, useEffect, useState } from 'react'; +import React, { + createContext, + PropsWithChildren, + useEffect, + useState, +} from 'react'; import { sidebarConfig } from './config'; import { BackstageTheme } from '@backstage/theme'; import { LocalStorage } from './localStorage'; @@ -44,7 +49,7 @@ export const SidebarPinStateContext = createContext( }, ); -export const SidebarPage: FC<{}> = props => { +export const SidebarPage = (props: PropsWithChildren<{}>) => { const [isPinned, setIsPinned] = useState(() => LocalStorage.getSidebarPinState(), ); diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index ebc9bf278f..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, { FC } 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: FC = ({ +type SingleSignInPageProps = SignInPageProps & { + provider: SignInConfig; + auto?: boolean; +}; + +export type Props = MultiSignInPageProps | SingleSignInPageProps; + +export const MultiSignInPage = ({ onResult, providers = [], title, align = 'left', -}) => { +}: MultiSignInPageProps) => { const configApi = useApi(configApiRef); const classes = useStyles(); @@ -69,3 +77,86 @@ export const SignInPage: FC = ({ ); }; + +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/core/src/layout/TabbedCard/TabbedCard.tsx b/packages/core/src/layout/TabbedCard/TabbedCard.tsx index e1967b8533..c9ae2eebc8 100644 --- a/packages/core/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core/src/layout/TabbedCard/TabbedCard.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import React, { FC, useState, ReactElement, ReactNode } from 'react'; +import React, { + useState, + ReactElement, + ReactNode, + PropsWithChildren, +} from 'react'; import { Card, CardContent, @@ -55,14 +60,14 @@ type Props = { deepLink?: BottomLinkProps; }; -const TabbedCard: FC = ({ +const TabbedCard = ({ slackChannel = '#backstage', children, title, deepLink, value, onChange, -}) => { +}: PropsWithChildren) => { const tabsClasses = useTabsStyles(); const [selectedIndex, selectIndex] = useState(0); @@ -118,7 +123,7 @@ type CardTabProps = TabProps & { children: ReactNode; }; -const CardTab: FC = ({ children, ...props }) => { +const CardTab = ({ children, ...props }: PropsWithChildren) => { const classes = useCardTabStyles(); return ; diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index d97d30982e..2ab1ebaf5b 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -28,3 +28,4 @@ export * from './Page'; export * from './Sidebar'; export * from './SignInPage'; export * from './TabbedCard'; +export * from './Breadcrumbs'; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index e13e31f406..de59a343ed 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,1021 @@ # @backstage/create-app +## 0.3.11 + +### Patch Changes + +- 4594f7efc: Add the google analytics scripts in the `index.html` template for new applications. + + To apply this change to an existing application, change the following in `packages\app\public\index.html`: + + ```diff + <%= app.title %> + + + <% if (app.googleAnalyticsTrackingId && typeof app.googleAnalyticsTrackingId + + === 'string') { %> + + + + + + <% } %> + + ``` + +- 08fa2176a: **BREAKING CHANGE** + + The Scaffolder and Catalog plugins have been migrated to partially require use of the [new composability API](https://backstage.io/docs/plugins/composability). The Scaffolder used to register its pages using the deprecated route registration plugin API, but those registrations have been removed. This means you now need to add the Scaffolder plugin page to the app directly. + + The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. + + Apply the following changes to `packages/app/src/App.tsx`: + + ```diff + -import { Router as CatalogRouter } from '@backstage/plugin-catalog'; + +import { + + catalogPlugin, + + CatalogIndexPage, + + CatalogEntityPage, + +} from '@backstage/plugin-catalog'; + +import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; + + # The following addition to the app config allows the catalog plugin to link to the + # component creation page, i.e. the scaffolder. You can chose a different target if you want to. + const app = createApp({ + apis, + plugins: Object.values(plugins), + + bindRoutes({ bind }) { + + bind(catalogPlugin.externalRoutes, { + + createComponent: scaffolderPlugin.routes.root, + + }); + + } + }); + + # Apply these changes within FlatRoutes. It is important to have migrated to using FlatRoutes + # for this to work, if you haven't done that yet, see the previous entries in this changelog. + - } + - /> + + } /> + + } + + > + + + + + } /> + + } /> + ``` + + The scaffolder has been redesigned to be horizontally scalable and to persistently store task state and execution logs in the database. Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffolder backend instead of the old `CatalogEntityClient`. + + The default catalog client comes from the `@backstage/catalog-client`, which you need to add as a dependency in `packages/backend/package.json`. + + Once the dependency has been added, apply the following changes to`packages/backend/src/plugins/scaffolder.ts`: + + ```diff + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, + - CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + +import { CatalogClient } from '@backstage/catalog-client'; + + const discovery = SingleHostDiscovery.fromConfig(config); + -const entityClient = new CatalogEntityClient({ discovery }); + +const catalogClient = new CatalogClient({ discoveryApi: discovery }) + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + - entityClient, + database, + + catalogClient, + }); + ``` + + See the `@backstage/scaffolder-backend` changelog for more information about this change. + +- Updated dependencies [ec504e7b4] +- Updated dependencies [3a58084b6] +- Updated dependencies [a5f42cf66] +- Updated dependencies [e488f0502] +- Updated dependencies [e799e74d4] +- Updated dependencies [dc12852c9] +- Updated dependencies [a5f42cf66] +- Updated dependencies [a8953a9c9] +- Updated dependencies [f37992797] +- Updated dependencies [347137ccf] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [d6593abe6] +- Updated dependencies [bad21a085] +- Updated dependencies [e8e35fb5f] +- Updated dependencies [9615e68fb] +- Updated dependencies [e780e119c] +- Updated dependencies [437bac549] +- Updated dependencies [9f2b3a26e] +- Updated dependencies [49f9b7346] +- Updated dependencies [1c06cb312] +- Updated dependencies [968b588f7] +- Updated dependencies [3a58084b6] +- Updated dependencies [2499f6cde] +- Updated dependencies [5469a9761] +- Updated dependencies [a1f5e6545] +- Updated dependencies [60d1bc3e7] +- Updated dependencies [2c1f2a7c2] +- Updated dependencies [6266ddd11] + - @backstage/plugin-auth-backend@0.3.2 + - @backstage/core@0.6.3 + - @backstage/plugin-scaffolder@0.6.0 + - @backstage/plugin-scaffolder-backend@0.8.0 + - @backstage/test-utils@0.1.8 + - @backstage/plugin-catalog@0.4.0 + - @backstage/plugin-catalog-import@0.4.2 + - @backstage/plugin-techdocs@0.5.8 + - @backstage/plugin-techdocs-backend@0.6.2 + - @backstage/plugin-explore@0.2.7 + - @backstage/plugin-api-docs@0.4.7 + - @backstage/catalog-model@0.7.2 + - @backstage/cli@0.6.2 + - @backstage/plugin-tech-radar@0.3.6 + - @backstage/plugin-app-backend@0.3.8 + - @backstage/plugin-catalog-backend@0.6.3 + - @backstage/config@0.1.3 + - @backstage/plugin-circleci@0.2.10 + - @backstage/plugin-github-actions@0.3.4 + - @backstage/plugin-lighthouse@0.2.12 + - @backstage/plugin-search@0.3.2 + +## 0.3.10 + +### Patch Changes + +- d50e9b81e: Updated docker build to use `backstage-cli backend:bundle` instead of `backstage-cli backend:build-image`. + + To apply this change to an existing application, change the following in `packages/backend/package.json`: + + ```diff + - "build": "backstage-cli backend:build", + - "build-image": "backstage-cli backend:build-image --build --tag backstage", + + "build": "backstage-cli backend:bundle", + + "build-image": "docker build ../.. -f Dockerfile --tag backstage", + ``` + + Note that the backend build is switched to `backend:bundle`, and the `build-image` script simply calls `docker build`. This means the `build-image` script no longer builds all packages, so you have to run `yarn build` in the root first. + + In order to work with the new build method, the `Dockerfile` at `packages/backend/Dockerfile` has been updated with the following contents: + + ```dockerfile + # This dockerfile builds an image for the backend package. + # It should be executed with the root of the repo as docker context. + # + # Before building this image, be sure to have run the following commands in the repo root: + # + # yarn install + # yarn tsc + # yarn build + # + # Once the commands have been run, you can build the image using `yarn build-image` + + FROM node:14-buster-slim + + WORKDIR /app + + # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. + # The skeleton contains the package.json of each package in the monorepo, + # and along with yarn.lock and the root package.json, that's enough to run yarn install. + ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ + + RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + + # Then copy the rest of the backend bundle, along with any other files we might want. + ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + + CMD ["node", "packages/backend", "--config", "app-config.yaml"] + ``` + + Note that the base image has been switched from `node:14-buster` to `node:14-buster-slim`, significantly reducing the image size. This is enabled by the removal of the `nodegit` dependency, so if you are still using this in your project you will have to stick with the `node:14-buster` base image. + + A `.dockerignore` file has been added to the root of the repo as well, in order to keep the docker context upload small. It lives in the root of the repo with the following contents: + + ```gitignore + .git + node_modules + packages + !packages/backend/dist + plugins + ``` + +- 532bc0ec0: Upgrading to lerna@4.0.0. +- Updated dependencies [16fb1d03a] +- Updated dependencies [92f01d75c] +- Updated dependencies [6c4a76c59] +- Updated dependencies [32a950409] +- Updated dependencies [491f3a0ec] +- Updated dependencies [f10950bd2] +- Updated dependencies [914c89b13] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [257a753ff] +- Updated dependencies [d872f662d] +- Updated dependencies [edbc27bfd] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] +- Updated dependencies [9337f509d] +- Updated dependencies [0ada34a0f] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [d9687c524] +- Updated dependencies [53b69236d] +- Updated dependencies [29c8bcc53] +- Updated dependencies [3600ac3b0] +- Updated dependencies [07e226872] +- Updated dependencies [b0a41c707] +- Updated dependencies [f62e7abe5] +- Updated dependencies [a341a8716] +- Updated dependencies [96f378d10] +- Updated dependencies [532bc0ec0] +- Updated dependencies [688b73110] + - @backstage/backend-common@0.5.4 + - @backstage/plugin-auth-backend@0.3.1 + - @backstage/plugin-scaffolder@0.5.1 + - @backstage/plugin-catalog@0.3.2 + - @backstage/core@0.6.2 + - @backstage/cli@0.6.1 + - @backstage/plugin-user-settings@0.2.6 + - @backstage/plugin-scaffolder-backend@0.7.1 + - @backstage/plugin-api-docs@0.4.6 + - @backstage/plugin-catalog-import@0.4.1 + - @backstage/plugin-github-actions@0.3.3 + - @backstage/plugin-lighthouse@0.2.11 + - @backstage/plugin-techdocs-backend@0.6.1 + - @backstage/plugin-catalog-backend@0.6.2 + - @backstage/plugin-circleci@0.2.9 + - @backstage/plugin-explore@0.2.6 + - @backstage/plugin-search@0.3.1 + - @backstage/plugin-techdocs@0.5.7 + +## 0.3.9 + +### Patch Changes + +- 615103a63: 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, + }); + } + ``` + +- 30e200d12: `@backstage/plugin-catalog-import` has been refactored, so the `App.tsx` of the backstage apps need to be updated: + + ```diff + // packages/app/src/App.tsx + + } + + element={} + /> + ``` + +- f4b576d0e: TechDocs: Add comments about migrating away from basic setup in app-config.yaml +- Updated dependencies [753bb4c40] +- Updated dependencies [1deb31141] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [77ad0003a] +- Updated dependencies [6b26c9f41] +- Updated dependencies [b3f0c3811] +- Updated dependencies [d2441aee3] +- Updated dependencies [727f0deec] +- Updated dependencies [fb53eb7cb] +- Updated dependencies [07bafa248] +- Updated dependencies [ca559171b] +- Updated dependencies [ffffea8e6] +- Updated dependencies [f5e564cd6] +- Updated dependencies [f3fbfb452] +- Updated dependencies [615103a63] +- Updated dependencies [68dd79d83] +- Updated dependencies [84364b35c] +- Updated dependencies [41af18227] +- Updated dependencies [82b2c11b6] +- Updated dependencies [1df75733e] +- Updated dependencies [965e200c6] +- Updated dependencies [b51ee6ece] +- Updated dependencies [e5da858d7] +- Updated dependencies [9230d07e7] +- Updated dependencies [f5f45744e] +- Updated dependencies [0fe8ff5be] +- Updated dependencies [5a5163519] +- Updated dependencies [82b2c11b6] +- Updated dependencies [8f3443427] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] +- Updated dependencies [b51ee6ece] +- Updated dependencies [804502a5c] + - @backstage/plugin-catalog-import@0.4.0 + - @backstage/plugin-auth-backend@0.3.0 + - @backstage/plugin-catalog@0.3.1 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-scaffolder-backend@0.7.0 + - @backstage/plugin-catalog-backend@0.6.1 + - @backstage/plugin-circleci@0.2.8 + - @backstage/plugin-search@0.3.0 + - @backstage/plugin-app-backend@0.3.7 + - @backstage/backend-common@0.5.3 + - @backstage/plugin-api-docs@0.4.5 + - @backstage/plugin-lighthouse@0.2.10 + - @backstage/plugin-techdocs@0.5.6 + - @backstage/test-utils@0.1.7 + - @backstage/plugin-github-actions@0.3.2 + - @backstage/plugin-explore@0.2.5 + - @backstage/plugin-techdocs-backend@0.6.0 + - @backstage/core@0.6.1 + - @backstage/plugin-tech-radar@0.3.5 + +## 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 + +- 4e0e3b1bf: Add missing `yarn clean` for app. + + For users with existing Backstage installations, add the following under the `scripts` section in `packages/app/package.json`, after the "lint" entry: + + ```json + "clean": "backstage-cli clean", + ``` + + This will add the missing `yarn clean` for the generated frontend. + +- 352a6581f: Added `"start-backend"` script to root `package.json`. + + To apply this change to an existing app, add the following script to the root `package.json`: + + ```json + "start-backend": "yarn workspace backend start" + ``` + +## 0.3.0 + +### Minor Changes + +- 0101c7a16: Add search plugin to default template for CLI created apps + +### Patch Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +## 0.2.5 + +### Patch Changes + +- 2783ec018: In the techdocs-backend plugin (`packages/backend/src/plugins/techdocs.ts`), create a publisher using + + ``` + const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + instead of + + ``` + const publisher = new LocalPublish(logger, discovery); + ``` + + An instance of `publisher` can either be a local filesystem publisher or a Google Cloud Storage publisher. + + Read more about the configs here https://backstage.io/docs/features/techdocs/configuration + (You will also have to update `techdocs.storage.type` to `local` or `googleGcs`. And `techdocs.builder` to either `local` or `external`.) + +## 0.2.4 + +### Patch Changes + +- 94348441e: Add `"files": ["dist"]` to both app and backend packages. This ensures that packaged versions of these packages do not contain unnecessary files. + + To apply this change to an existing app, add the following to `packages/app/package.json` and `packages/backend/package.json`: + + ```json + "files": [ + "dist" + ] + ``` + +- cb5fc4b29: Adjust template to the latest changes in the `api-docs` plugin. + + ## Template Changes + + While updating to the latest `api-docs` plugin, the following changes are + necessary for the `create-app` template in your + `app/src/components/catalog/EntityPage.tsx`. This adds: + + - A custom entity page for API entities + - Changes the API tab to include the new `ConsumedApisCard` and + `ProvidedApisCard` that link to the API entity. + + ```diff + import { + + ApiDefinitionCard, + - Router as ApiDocsRouter, + + ConsumedApisCard, + + ProvidedApisCard, + + ConsumedApisCard, + + ConsumingComponentsCard, + + ProvidedApisCard, + + ProvidingComponentsCard + } from '@backstage/plugin-api-docs'; + + ... + + +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + + + + + +); + + const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + + element={} + /> + ... + + -export const EntityPage = () => { + - const { entity } = useEntity(); + - switch (entity?.spec?.type) { + - case 'service': + - return ; + - case 'website': + - return ; + - default: + - return ; + - } + -}; + + +export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { + + switch (entity?.spec?.type) { + + case 'service': + + return ; + + case 'website': + + return ; + + default: + + return ; + + } + +}; + + + +const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + + + +const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( + + + + + + + + + + + +); + + + +const ApiEntityPage = ({ entity }: { entity: Entity }) => ( + + + + } + + /> + + } + + /> + + + +); + + + +export const EntityPage = () => { + + const { entity } = useEntity(); + + + + switch (entity?.kind?.toLowerCase()) { + + case 'component': + + return ; + + case 'api': + + return ; + + default: + + return ; + + } + +}; + ``` + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions + ## 0.2.3 ### Patch Changes @@ -212,7 +1228,7 @@ --config ../../app-config.yaml --config ../../app-config.development.yaml ``` -- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens. +- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for GitHub, GitLab, Azure & GitHub Enterprise access tokens. ### Detail: diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 7533110372..dafc01e365 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -1,9 +1,10 @@ # @backstage/create-app -This package provides a CLI for creating apps. +This package provides a CLI for creating a copy of the Backstage app. + You can use the flag `--skip-install` to skip the install. -## Installation +## Usage With `npx`: @@ -11,6 +12,12 @@ With `npx`: $ npx @backstage/create-app ``` +With a local clone of this repo, from the main `create-app/` folder, run: + +```sh +$ yarn backstage-create-app +``` + ## Documentation - [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) diff --git a/packages/create-app/bin/backstage-create-app b/packages/create-app/bin/backstage-create-app index 467a0cae8f..902f341bfb 100755 --- a/packages/create-app/bin/backstage-create-app +++ b/packages/create-app/bin/backstage-create-app @@ -24,6 +24,8 @@ const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { require('..'); } else { + // Only used for development, so should be a devDependency + // eslint-disable-next-line import/no-extraneous-dependencies require('ts-node').register({ transpileOnly: true, project: path.resolve(__dirname, '../../../tsconfig.json'), diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 384a6f11a2..05615cc78d 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.2.3", + "version": "0.3.11", "private": false, "publishConfig": { "access": "public" @@ -37,38 +37,46 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/cli": "^0.4.0", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/plugin-api-docs": "^0.3.1", - "@backstage/plugin-app-backend": "^0.3.2", - "@backstage/plugin-auth-backend": "^0.2.5", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/plugin-catalog-backend": "^0.3.0", - "@backstage/plugin-circleci": "^0.2.3", - "@backstage/plugin-explore": "^0.2.1", - "@backstage/plugin-github-actions": "^0.2.3", - "@backstage/plugin-lighthouse": "^0.2.4", - "@backstage/plugin-proxy-backend": "^0.2.2", - "@backstage/plugin-register-component": "^0.2.3", - "@backstage/plugin-rollbar-backend": "^0.1.4", - "@backstage/plugin-scaffolder": "^0.3.2", - "@backstage/plugin-scaffolder-backend": "^0.3.3", - "@backstage/plugin-tech-radar": "^0.3.0", - "@backstage/plugin-techdocs": "^0.3.1", - "@backstage/plugin-techdocs-backend": "^0.3.1", - "@backstage/plugin-user-settings": "^0.2.2", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", "@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": "*", + "@backstage/catalog-client": "*", + "@backstage/catalog-model": "*", + "@backstage/cli": "*", + "@backstage/config": "*", + "@backstage/core": "*", + "@backstage/plugin-api-docs": "*", + "@backstage/plugin-app-backend": "*", + "@backstage/plugin-auth-backend": "*", + "@backstage/plugin-catalog": "*", + "@backstage/plugin-catalog-backend": "*", + "@backstage/plugin-catalog-import": "*", + "@backstage/plugin-circleci": "*", + "@backstage/plugin-explore": "*", + "@backstage/plugin-github-actions": "*", + "@backstage/plugin-lighthouse": "*", + "@backstage/plugin-proxy-backend": "*", + "@backstage/plugin-rollbar-backend": "*", + "@backstage/plugin-scaffolder": "*", + "@backstage/plugin-search": "*", + "@backstage/plugin-scaffolder-backend": "*", + "@backstage/plugin-tech-radar": "*", + "@backstage/plugin-techdocs": "*", + "@backstage/plugin-techdocs-backend": "*", + "@backstage/plugin-user-settings": "*", + "@backstage/test-utils": "*", + "@backstage/theme": "*" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "bin/backstage-create-app", + "ext": "ts" + }, "files": [ "bin", "dist", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 84f015509c..9a05a95ac0 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -14,49 +14,55 @@ * 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 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 pluginRegisterComponent } from '@backstage/plugin-register-component/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 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 catalogClient } from '../../../catalog-client/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, + '@backstage/catalog-client': catalogClient, '@backstage/catalog-model': catalogModel, '@backstage/cli': cli, '@backstage/config': config, @@ -65,16 +71,18 @@ 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, '@backstage/plugin-explore': pluginExplore, '@backstage/plugin-github-actions': pluginGithubActions, '@backstage/plugin-lighthouse': pluginLighthouse, '@backstage/plugin-proxy-backend': pluginProxyBackend, - '@backstage/plugin-register-component': pluginRegisterComponent, '@backstage/plugin-rollbar-backend': pluginRollbarBackend, '@backstage/plugin-scaffolder': pluginScaffolder, '@backstage/plugin-scaffolder-backend': pluginScaffolderBackend, + '@backstage/plugin-search': pluginSearch, '@backstage/plugin-tech-radar': pluginTechRadar, '@backstage/plugin-techdocs': pluginTechdocs, '@backstage/plugin-techdocs-backend': pluginTechdocsBackend, diff --git a/packages/create-app/templates/default-app/.dockerignore b/packages/create-app/templates/default-app/.dockerignore new file mode 100644 index 0000000000..63c9c34286 --- /dev/null +++ b/packages/create-app/templates/default-app/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +packages +!packages/backend/dist +plugins 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 595812dec3..f8a345cd1e 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -56,14 +56,16 @@ proxy: target: 'https://example.com' changeOrigin: true +# Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' - -lighthouse: - baseUrl: http://localhost:3003 + techdocs: 'docker' # Alternatives - 'local' + publisher: + 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 @@ -88,23 +90,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 975a5a2ed2..58fb20ba05 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -7,6 +7,7 @@ }, "scripts": { "start": "yarn workspace app start", + "start-backend": "yarn workspace backend start", "build": "lerna run build", "build-image": "yarn workspace backend build-image", "tsc": "tsc", @@ -29,7 +30,7 @@ "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", "@spotify/prettier-config": "^7.0.0", - "lerna": "^3.20.2", + "lerna": "^4.0.0", "prettier": "^1.19.1" }, "prettier": "@spotify/prettier-config", @@ -44,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 3f04773e31..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,15 +10,16 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", - "@backstage/plugin-register-component": "^{{version '@backstage/plugin-register-component'}}", + "@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'}}", + "@backstage/plugin-search": "^{{version '@backstage/plugin-search'}}", "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@backstage/theme": "^{{version '@backstage/theme'}}", "history": "^5.0.0", @@ -45,6 +46,7 @@ "build": "backstage-cli app:build", "test": "backstage-cli test", "lint": "backstage-cli lint", + "clean": "backstage-cli clean", "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", "cy:dev": "cypress open", @@ -61,5 +63,8 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "files": [ + "dist" + ] } diff --git a/packages/create-app/templates/default-app/packages/app/public/index.html b/packages/create-app/templates/default-app/packages/app/public/index.html index ea9208ca57..5653173480 100644 --- a/packages/create-app/templates/default-app/packages/app/public/index.html +++ b/packages/create-app/templates/default-app/packages/app/public/index.html @@ -48,6 +48,22 @@ } <%= app.title %> + <% if (app.googleAnalyticsTrackingId && typeof app.googleAnalyticsTrackingId + === 'string') { %> + + + <% } %> 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 9449ac68a1..b2c9d10eb0 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 @@ -1,61 +1,72 @@ -import React, { FC } from 'react'; import { - createApp, AlertDisplay, + createApp, + FlatRoutes, OAuthRequestDialog, SidebarPage, - createRouteRef, } from '@backstage/core'; +import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; +import { + CatalogEntityPage, + CatalogIndexPage, + catalogPlugin, +} from '@backstage/plugin-catalog'; +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; +import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { SearchPage } from '@backstage/plugin-search'; +import { TechRadarPage } from '@backstage/plugin-tech-radar'; +import { TechdocsPage } from '@backstage/plugin-techdocs'; +import { UserSettingsPage } from '@backstage/plugin-user-settings'; +import React from 'react'; +import { Navigate, Route } from 'react-router'; import { apis } from './apis'; +import { EntityPage } from './components/catalog/EntityPage'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; -import { Route, Routes, Navigate } from 'react-router'; -import { Router as CatalogRouter } from '@backstage/plugin-catalog'; -import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; - -import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ apis, plugins: Object.values(plugins), + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + bind(apiDocsPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, }); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const deprecatedAppRoutes = app.getRoutes(); -const catalogRouteRef = createRouteRef({ - path: '/catalog', - title: 'Service Catalog', -}); - - -const App: FC<{}> = () => ( +const App = () => ( - + + } /> } - /> - } /> + path="/catalog/:namespace/:kind/:name" + element={} + > + + + } /> + } /> + } /> } + element={} /> - } - /> - {deprecatedAppRoutes} - + } /> + } /> + } /> + diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx index d2b1bf1080..2fb767465b 100644 --- a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -26,7 +26,7 @@ const useStyles = makeStyles({ fill: '#7df3e1', }, }); -const LogoFull: FC<{}> = () => { +const LogoFull = () => { const classes = useStyles(); return ( diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx index d70be3dd32..507e47ddb9 100644 --- a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -27,7 +27,7 @@ const useStyles = makeStyles({ }, }); -const LogoIcon: FC<{}> = () => { +const LogoIcon = () => { const classes = useStyles(); return ( 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 7d65b3261a..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 @@ -13,26 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Router as GitHubActionsRouter, - isPluginApplicableToEntity as isGitHubActionsAvailable, -} from '@backstage/plugin-github-actions'; -import { - Router as CircleCIRouter, - isPluginApplicableToEntity as isCircleCIAvailable, -} from '@backstage/plugin-circleci'; -import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; -import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; - -import React from 'react'; -import { - EntityPageLayout, - useEntity, - AboutCard, -} from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { Grid } from '@material-ui/core'; +import { ApiEntity, Entity } from '@backstage/catalog-model'; import { WarningPanel } from '@backstage/core'; +import { + ApiDefinitionCard, + ConsumedApisCard, + ConsumingComponentsCard, + ProvidedApisCard, + ProvidingComponentsCard +} from '@backstage/plugin-api-docs'; +import { + AboutCard, EntityPageLayout, +} from '@backstage/plugin-catalog'; +import { + useEntity +} from '@backstage/plugin-catalog-react'; +import { + isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter +} from '@backstage/plugin-circleci'; +import { + isPluginApplicableToEntity as isGitHubActionsAvailable, Router as GitHubActionsRouter +} from '@backstage/plugin-github-actions'; +import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { Grid } from '@material-ui/core'; +import React from 'react'; + const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -60,6 +65,17 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + +); + const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ( } + element={} /> ( ); -export const EntityPage = () => { - const { entity } = useEntity(); +export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { switch (entity?.spec?.type) { case 'service': return ; @@ -131,3 +146,55 @@ export const EntityPage = () => { return ; } }; + +const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + +); + +const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( + + + + + +); + +const ApiEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLowerCase()) { + case 'component': + return ; + case 'api': + return ; + default: + return ; + } +}; 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 9eabff0044..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 @@ -1,9 +1,10 @@ export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; +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 ScaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; -export { plugin as Circleci } from '@backstage/plugin-circleci'; -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; -export { plugin as GithubActions } from '@backstage/plugin-github-actions'; +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 b5425b66b6..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 @@ -1,10 +1,8 @@ -import React, { FC, useContext } from 'react'; +import React, { useContext } from 'react'; 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'; @@ -20,27 +18,8 @@ import { SidebarContext, SidebarSpace, } from '@backstage/core'; +import { SidebarSearch } from '@backstage/plugin-search'; -export const AppSidebar = () => ( - - - - {/* Global nav, not org-specific */} - - - - - - - - - {/* End global nav */} - - - - - -); const useSidebarLogoStyles = makeStyles({ root: { @@ -57,7 +36,7 @@ const useSidebarLogoStyles = makeStyles({ }, }); -const SidebarLogo: FC<{}> = () => { +const SidebarLogo = () => { const classes = useSidebarLogoStyles(); const { isOpen } = useContext(SidebarContext); @@ -74,3 +53,23 @@ const SidebarLogo: FC<{}> = () => { ); }; + +export const AppSidebar = () => ( + + + + + {/* Global nav, not org-specific */} + + + + + + + {/* End global nav */} + + + + + +); diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index 50514713d3..acef405c8a 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -1,16 +1,26 @@ -FROM node:12-buster +# This dockerfile builds an image for the backend package. +# It should be executed with the root of the repo as docker context. +# +# Before building this image, be sure to have run the following commands in the repo root: +# +# yarn install +# yarn tsc +# yarn build +# +# Once the commands have been run, you can build the image using `yarn build-image` -WORKDIR /usr/src/app +FROM node:14-buster-slim + +WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json skeleton.tar ./ +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -# This will copy the contents of the dist-workspace when running the build-image command. -# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. -COPY . . +# Then copy the rest of the backend bundle, along with any other files we might want. +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ -CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"] +CMD ["node", "packages/backend", "--config", "app-config.yaml"] 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 07d1b0be58..8c8cfae0b3 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 @@ -8,8 +8,8 @@ "node": "12 || 14" }, "scripts": { - "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image --build --tag backstage", + "build": "backstage-cli backend:bundle", + "build-image": "docker build ../.. -f Dockerfile --tag backstage", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -20,6 +20,7 @@ "app": "0.0.0", "@backstage/backend-common": "^{{version '@backstage/backend-common'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", + "@backstage/catalog-client": "^{{version '@backstage/catalog-client'}}", "@backstage/config": "^{{version '@backstage/config'}}", "@backstage/plugin-app-backend": "^{{version '@backstage/plugin-app-backend'}}", "@backstage/plugin-auth-backend": "^{{version '@backstage/plugin-auth-backend'}}", @@ -27,9 +28,9 @@ "@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", - "dockerode": "^3.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", "knex": "^0.21.6", @@ -37,15 +38,17 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", {{/if}} "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@types/dockerode": "^2.5.32", + "@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/app.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/app.ts index 51316949cb..07fb04fca2 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/app.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/app.ts @@ -1,10 +1,11 @@ import { createRouter } from '@backstage/plugin-app-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts index fe19855d5d..52165104a2 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts @@ -1,4 +1,5 @@ import { createRouter } from '@backstage/plugin-auth-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -6,6 +7,6 @@ export default async function createPlugin({ database, config, discovery, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index 10ac116ded..883b70c587 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -2,11 +2,12 @@ import { useHotCleanup } from '@backstage/backend-common'; import { CatalogBuilder, createRouter, - runPeriodically, + runPeriodically } from '@backstage/plugin-catalog-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; -export default async function createPlugin(env: PluginEnvironment) { +export default async function createPlugin(env: PluginEnvironment): Promise { const builder = new CatalogBuilder(env); const { entitiesCatalog, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts index 388d3fc446..506f6d98f9 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts @@ -1,11 +1,11 @@ -// @ts-ignore import { createRouter } from '@backstage/plugin-proxy-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, discovery, -}: PluginEnvironment) { +}: PluginEnvironment): Promise { return await createRouter({ logger, config, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 2dc69feb45..49cd8a9cda 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,107 +1,39 @@ +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; import { CookieCutter, - createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, + + + + CreateReactAppTemplater, createRouter, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, - CreateReactAppTemplater, - Templaters, - RepoVisibilityOptions, - CatalogEntityClient, + + Templaters } 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'; +import { Router } from 'express'; +import type { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, -}: PluginEnvironment) { + database, +}: PluginEnvironment): Promise { 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(); const discovery = SingleHostDiscovery.fromConfig(config); - const entityClient = new CatalogEntityClient({ discovery }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ preparers, @@ -110,6 +42,7 @@ export default async function createPlugin({ logger, config, dockerClient, - entityClient, + database, + catalogClient, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index ac4d81a8e8..0ba08cefaf 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -1,35 +1,40 @@ import { createRouter, - DirectoryPreparer, - CommonGitPreparer, - Preparers, - Generators, - LocalPublish, - TechdocsGenerator, + + Generators, Preparers, + + Publisher } from '@backstage/plugin-techdocs-backend'; -import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, discovery, -}: PluginEnvironment) { - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); + reader, +}: PluginEnvironment): Promise { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); - generators.register('techdocs', techdocsGenerator); + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); - const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(logger); - const commonGitPreparer = new CommonGitPreparer(logger); - - preparers.register('dir', directoryPreparer); - preparers.register('github', commonGitPreparer); - preparers.register('gitlab', commonGitPreparer); - - const publisher = new LocalPublish(logger, discovery); + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 9a1048aa3d..f02ce3ae23 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,138 @@ # @backstage/dev-utils +## 0.1.12 + +### Patch Changes + +- 5aa4ceea6: Make sure to provide dummy routes for all external routes of plugins given to DevApp +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [dc12852c9] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/test-utils@0.1.8 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.1.11 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.1.10 + +### Patch Changes + +- Updated dependencies [b51ee6ece] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/test-utils@0.1.7 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 696b8ce74: Add new `addPage` method for use with extensions, as well as an `EntityGridItem` to easily create different test cases for entity overview cards. +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [87c0c53c2] + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + +## 0.1.6 + +### Patch Changes + +- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + + This fixes for example the following code: + + ```ts + interface MyApi { + myMethod(): void + } + + const myApiRef = createApiRef({...}); + + createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), + }) + ``` + +- e1f4e24ef: Fix @backstage/cli not being a devDependency +- Updated dependencies [2527628e1] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + ## 0.1.5 ### Patch Changes diff --git a/packages/dev-utils/README.md b/packages/dev-utils/README.md index 1aa86ba51c..4b08a5ca6d 100644 --- a/packages/dev-utils/README.md +++ b/packages/dev-utils/README.md @@ -6,7 +6,7 @@ This package provides utilities that help in developing plugins for Backstage, l ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save-dev @backstage/dev-utils diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 4ceb0cec5e..54c42ac70c 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.5", + "version": "0.1.12", "private": false, "publishConfig": { "access": "public", @@ -29,14 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/core": "^0.3.1", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@backstage/catalog-model": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/test-utils": "^0.1.8", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/react": "^16.9", "react": "^16.12.0", @@ -46,6 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { + "@backstage/cli": "^0.6.2", "@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 new file mode 100644 index 0000000000..825a207064 --- /dev/null +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.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 { Entity } from '@backstage/catalog-model'; +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 }) => ({ + position: 'relative', + + '&::before': { + content: `"${entity.metadata.name}"`, + top: -theme.typography.fontSize + 4, + display: 'block', + position: 'absolute', + color: theme.palette.textSubtle, + }, + }), +})); + +export const EntityGridItem = ({ + entity, + classes, + ...rest +}: Omit & { entity: Entity }): JSX.Element => { + const itemClasses = useStyles({ entity }); + + return ( + + + + ); +}; diff --git a/packages/dev-utils/src/components/EntityGridItem/index.ts b/packages/dev-utils/src/components/EntityGridItem/index.ts new file mode 100644 index 0000000000..e0f07b13ba --- /dev/null +++ b/packages/dev-utils/src/components/EntityGridItem/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 { EntityGridItem } from './EntityGridItem'; diff --git a/plugins/api-docs/src/catalog/index.ts b/packages/dev-utils/src/components/index.ts similarity index 94% rename from plugins/api-docs/src/catalog/index.ts rename to packages/dev-utils/src/components/index.ts index 4c177df914..34224f09fd 100644 --- a/plugins/api-docs/src/catalog/index.ts +++ b/packages/dev-utils/src/components/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { Router } from './Router'; +export * from './EntityGridItem'; diff --git a/packages/dev-utils/src/devApp/render.test.tsx b/packages/dev-utils/src/devApp/render.test.tsx index fbebf2039b..9cb680c53b 100644 --- a/packages/dev-utils/src/devApp/render.test.tsx +++ b/packages/dev-utils/src/devApp/render.test.tsx @@ -33,7 +33,7 @@ describe('DevAppBuilder', () => { }; const DevApp = createDevApp() - .addRootChild() + .addRootChild() .build(); const rendered = render(); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index cca3470f3f..0c3a1395a6 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,24 +14,46 @@ * limitations under the License. */ -import { hot } from 'react-hot-loader/root'; -import React, { FC, ComponentType, ReactNode } from 'react'; -import ReactDOM from 'react-dom'; -import BookmarkIcon from '@material-ui/icons/Bookmark'; import { + AlertDisplay, + AnyApiFactory, + ApiFactory, + attachComponentData, createApp, - SidebarPage, + createPlugin, + createRouteRef, + FlatRoutes, + IconComponent, + OAuthRequestDialog, + RouteRef, Sidebar, SidebarItem, + SidebarPage, SidebarSpacer, - ApiFactory, - createPlugin, - AlertDisplay, - OAuthRequestDialog, - AnyApiFactory, } from '@backstage/core'; +import { Box } from '@material-ui/core'; +import BookmarkIcon from '@material-ui/icons/Bookmark'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; -import { Routes } from 'react-router'; +import React, { ComponentType, ReactNode } from 'react'; +import ReactDOM from 'react-dom'; +import { hot } from 'react-hot-loader'; +import { Route } from 'react-router'; + +const GatheringRoute: (props: { + path: string; + 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; +}; // TODO(rugvip): export proper plugin type from core that isn't the plugin class type BackstagePlugin = ReturnType; @@ -44,6 +66,8 @@ class DevAppBuilder { private readonly plugins = new Array(); private readonly apis = new Array(); private readonly rootChildren = new Array(); + private readonly routes = new Array(); + private readonly sidebarItems = new Array(); /** * Register one or more plugins to render in the dev app @@ -56,9 +80,11 @@ class DevAppBuilder { /** * Register an API factory to add to the app */ - registerApi( - factory: ApiFactory, - ): DevAppBuilder { + registerApi< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } + >(factory: ApiFactory): DevAppBuilder { this.apis.push(factory); return this; } @@ -73,13 +99,55 @@ class DevAppBuilder { return this; } + /** + * 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 */ build(): ComponentType<{}> { + const dummyRouteRef = createRouteRef({ title: 'Page of another plugin' }); + const DummyPage = () => Page belonging to another plugin.; + attachComponentData(DummyPage, 'core.mountPoint', dummyRouteRef); + const app = createApp({ apis: this.apis, plugins: this.plugins, + bindRoutes: ({ bind }) => { + for (const plugin of this.plugins ?? []) { + const targets: Record> = {}; + for (const routeKey of Object.keys(plugin.externalRoutes)) { + targets[routeKey] = dummyRouteRef; + } + bind(plugin.externalRoutes, targets); + } + }, }); const AppProvider = app.getProvider(); @@ -88,17 +156,20 @@ class DevAppBuilder { const sidebar = this.setupSidebar(this.plugins); - const DevApp: FC<{}> = () => { + const DevApp = () => { return ( {this.rootChildren} - {sidebar} - {deprecatedAppRoutes} + + {this.routes} + {deprecatedAppRoutes} + } /> + @@ -112,7 +183,12 @@ class DevAppBuilder { * Build and render directory to #root element, with react hot loading. */ render(): void { - const DevApp = hot(this.build()); + const hotModule = + require.cache['./dev/index.tsx'] ?? + require.cache['./dev/index.ts'] ?? + module; + + const DevApp = hot(hotModule)(this.build()); const paths = this.findPluginPaths(this.plugins); @@ -164,6 +240,7 @@ class DevAppBuilder { return ( + {this.sidebarItems} {sidebarItems} ); @@ -197,7 +274,7 @@ class DevAppBuilder { // this to provide their own plugin dev wrappers. /** - * Creates a dev app for rendering one or more plugins and exposing the touchpoints of the plugin. + * Creates a dev app for rendering one or more plugins and exposing the touch points of the plugin. */ export function createDevApp() { return new DevAppBuilder(); diff --git a/packages/dev-utils/src/index.ts b/packages/dev-utils/src/index.ts index c05e67ddbc..97add5dd86 100644 --- a/packages/dev-utils/src/index.ts +++ b/packages/dev-utils/src/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './components'; export * from './devApp'; diff --git a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts index 08e493600d..d7955e5566 100644 --- a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts +++ b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts @@ -27,7 +27,7 @@ const COMMIT_SHA = execSync('git rev-parse HEAD').toString('utf8').trim(); /** - * The GithubMarkdownPrinter is a MarkdownPrinter for printing Github-flavored markdown documents. + * The GithubMarkdownPrinter is a MarkdownPrinter for printing GitHub Flavored Markdown documents. */ export default class GithubMarkdownPrinter implements MarkdownPrinter { private str: string = ''; diff --git a/packages/docgen/src/docgen/types.ts b/packages/docgen/src/docgen/types.ts index 7aff54d04c..1d1d93a6ae 100644 --- a/packages/docgen/src/docgen/types.ts +++ b/packages/docgen/src/docgen/types.ts @@ -31,7 +31,7 @@ export type TypeLink = { }; /** - * TypeInfo describes a Typescript Type. + * TypeInfo describes a TypeScript Type. */ export type TypeInfo = { id: number; diff --git a/packages/e2e-test/.eslintrc.js b/packages/e2e-test/.eslintrc.js index ce2592e03b..7c8a092081 100644 --- a/packages/e2e-test/.eslintrc.js +++ b/packages/e2e-test/.eslintrc.js @@ -1,6 +1,5 @@ module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], - ignorePatterns: ['templates/**'], rules: { 'no-console': 0, 'import/no-extraneous-dependencies': [ 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 12a776d448..2f6192c047 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,170 @@ # @backstage/integration +## 0.5.0 + +### Minor Changes + +- 491f3a0ec: Make `ScmIntegration.resolveUrl` mandatory. + +## 0.4.0 + +### Minor Changes + +- ffffea8e6: Update the `GitLabIntegrationConfig` to require the fields `apiBaseUrl` and `baseUrl`. The `readGitLabIntegrationConfig` function is now more strict and has better error reporting. This change mirrors actual reality in code more properly - the fields are actually necessary for many parts of code to actually function, so they should no longer be optional. + + Some checks that used to happen deep inside code that consumed config, now happen upfront at startup. This means that you may encounter new errors at backend startup, if you had actual mistakes in config but didn't happen to exercise the code paths that actually would break. But for most users, no change will be necessary. + + An example minimal GitLab config block that just adds a token to public GitLab would look similar to this: + + ```yaml + integrations: + gitlab: + - host: gitlab.com + token: + $env: GITLAB_TOKEN + ``` + + A full fledged config that points to a locally hosted GitLab could look like this: + + ```yaml + integrations: + gitlab: + - host: gitlab.my-company.com + apiBaseUrl: https://gitlab.my-company.com/api/v4 + baseUrl: https://gitlab.my-company.com + token: + $env: OUR_GITLAB_TOKEN + ``` + + In this case, the only optional field is `baseUrl` which is formed from the `host` if needed. + +## 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 + +- 1d1c2860f: Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl +- 4eafdec4a: Introduce readTree method for GitLab URL Reader +- 178e09323: Validate that integration config contains a valid host + +## 0.1.3 + +### Patch Changes + +- 38e24db00: Move the core url and auth logic to integration for the four major providers +- b8ecf6f48: Add the basics of cross-integration concerns +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + ## 0.1.2 ### 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 342476c38f..a7cd63171b 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.1.2", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/integration" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,12 +29,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "git-url-parse": "^11.4.0" + "@backstage/config": "^0.1.2", + "cross-fetch": "^3.0.6", + "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.0", - "@types/jest": "^26.0.7" + "@backstage/cli": "^0.6.1", + "@backstage/test-utils": "^0.1.7", + "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", + "msw": "^0.21.2" }, "files": [ "dist", 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 new file mode 100644 index 0000000000..dddf6ed5d0 --- /dev/null +++ b/packages/integration/src/ScmIntegrations.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; +import { defaultScmResolveUrl } from './helpers'; +import { + ScmIntegration, + 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({ + azure: AzureIntegration.factory({ config }), + bitbucket: BitbucketIntegration.factory({ config }), + github: GitHubIntegration.factory({ config }), + gitlab: GitLabIntegration.factory({ config }), + }); + } + + 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 Object.values(this.byType).flatMap( + i => i.list() as ScmIntegration[], + ); + } + + 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 integration = this.byUrl(options.base); + if (!integration) { + return defaultScmResolveUrl(options); + } + + return integration.resolveUrl(options); + } +} diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts new file mode 100644 index 0000000000..3f9e165b90 --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { AzureIntegration } from './AzureIntegration'; + +describe('AzureIntegration', () => { + it('has a working factory', () => { + const integrations = AzureIntegration.factory({ + config: new ConfigReader({ + integrations: { + azure: [ + { + host: 'h.com', + token: 'token', + }, + ], + }, + }), + }); + 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', () => { + const integration = new AzureIntegration({ host: 'h.com' } as any); + 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?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 new file mode 100644 index 0000000000..1413b7b343 --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; + +export class AzureIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + return basicIntegrations( + configs.map(c => new AzureIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: AzureIntegrationConfig) {} + + get type(): string { + return 'azure'; + } + + get title(): string { + 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/config.test.ts b/packages/integration/src/azure/config.test.ts index 0b943f2081..eed88f09f6 100644 --- a/packages/integration/src/azure/config.test.ts +++ b/packages/integration/src/azure/config.test.ts @@ -23,7 +23,7 @@ import { describe('readAzureIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -60,9 +60,7 @@ describe('readAzureIntegrationConfig', () => { describe('readAzureIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { diff --git a/packages/integration/src/azure/config.ts b/packages/integration/src/azure/config.ts index 27b73ed707..5550e2faf5 100644 --- a/packages/integration/src/azure/config.ts +++ b/packages/integration/src/azure/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const AZURE_HOST = 'dev.azure.com'; @@ -47,6 +48,13 @@ export function readAzureIntegrationConfig( ): AzureIntegrationConfig { const host = config.getOptionalString('host') ?? AZURE_HOST; const token = config.getOptionalString('token'); + + if (!isValidHost(host)) { + throw new Error( + `Invalid Azure integration config, '${host}' is not a valid host`, + ); + } + return { host, token }; } diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts new file mode 100644 index 0000000000..438e776eef --- /dev/null +++ b/packages/integration/src/azure/core.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getAzureFileFetchUrl, + getAzureDownloadUrl, + getAzureRequestOptions, +} from './core'; + +describe('azure core', () => { + describe('getAzureRequestOptions', () => { + it('fills in the token if necessary', () => { + expect(getAzureRequestOptions({ host: '', token: '0123456789' })).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Basic OjAxMjM0NTY3ODk=', + }), + }), + ); + expect(getAzureRequestOptions({ host: '' })).toEqual( + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); + }); + + describe('getAzureFileFetchUrl', () => { + it.each([ + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }, + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }, + { + url: + 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + result: + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }, + { + url: + 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + result: + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }, + ])('should handle happy path %#', async ({ url, result }) => { + expect(getAzureFileFetchUrl(url)).toBe(result); + }); + + it.each([ + { + url: 'https://api.com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + }, + { + url: 'com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + }, + ])('should handle error path %#', ({ url, error }) => { + expect(() => getAzureFileFetchUrl(url)).toThrow(error); + }); + }); + + describe('getAzureDownloadUrl', () => { + it('do not add scopePath if no path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository', + ); + + expect(new URL(result).searchParams.get('scopePath')).toBeNull(); + }); + + it('add scopePath if a path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', + ); + expect(new URL(result).searchParams.get('scopePath')).toEqual('docs'); + }); + }); +}); diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts new file mode 100644 index 0000000000..b2878af89e --- /dev/null +++ b/packages/integration/src/azure/core.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { AzureIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents + * to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} + * + * @param url A URL pointing to a file + */ +export function getAzureFileFetchUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + const path = parsedUrl.searchParams.get('path') || ''; + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + empty !== '' || + userOrOrg === '' || + project === '' || + srcKeyword !== '_git' || + repoName === '' || + path === '' || + ref === '' + ) { + throw new Error('Wrong Azure Devops URL or Invalid file path'); + } + + // transform to api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'items', + ].join('/'); + + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url A URL pointing to a path + */ +export function getAzureDownloadUrl(url: string): string { + const { + name: repoName, + owner: project, + organization, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + // scopePath will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will only download index.md but put it in the root of the archive + const scopePath = filepath + ? `&scopePath=${encodeURIComponent(filepath)}` + : ''; + + 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. + * + * @param config The relevant provider config + */ +export function getAzureRequestOptions( + config: AzureIntegrationConfig, + additionalHeaders?: Record, +): RequestInit { + const headers: HeadersInit = additionalHeaders + ? { ...additionalHeaders } + : {}; + + if (config.token) { + const buffer = Buffer.from(`:${config.token}`, 'utf8'); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { headers }; +} diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index ede0c88a81..2d95ef19b9 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -14,8 +14,15 @@ * limitations under the License. */ +export { AzureIntegration } from './AzureIntegration'; export { readAzureIntegrationConfig, readAzureIntegrationConfigs, } from './config'; export type { AzureIntegrationConfig } from './config'; +export { + getAzureCommitsUrl, + getAzureDownloadUrl, + getAzureFileFetchUrl, + getAzureRequestOptions, +} from './core'; diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts new file mode 100644 index 0000000000..3f130a393c --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.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 { ConfigReader } from '@backstage/config'; +import { BitbucketIntegration } from './BitbucketIntegration'; + +describe('BitbucketIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + 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', () => { + const integration = new BitbucketIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('bitbucket'); + expect(integration.title).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts new file mode 100644 index 0000000000..10b69877e4 --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.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 { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketIntegrationConfig, + readBitbucketIntegrationConfigs, +} from './config'; + +export class BitbucketIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucket') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {} + + get type(): string { + return 'bitbucket'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } +} diff --git a/packages/integration/src/bitbucket/config.test.ts b/packages/integration/src/bitbucket/config.test.ts index 775a8b7d2d..9106afbe4f 100644 --- a/packages/integration/src/bitbucket/config.test.ts +++ b/packages/integration/src/bitbucket/config.test.ts @@ -23,7 +23,7 @@ import { describe('readBitbucketIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -83,9 +83,7 @@ describe('readBitbucketIntegrationConfig', () => { describe('readBitbucketIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index 1997a5597d..b7ad8684a9 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const BITBUCKET_HOST = 'bitbucket.org'; const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0'; @@ -75,6 +76,12 @@ export function readBitbucketIntegrationConfig( const username = config.getOptionalString('username'); const appPassword = config.getOptionalString('appPassword'); + if (!isValidHost(host)) { + throw new Error( + `Invalid Bitbucket integration config, '${host}' is not a valid host`, + ); + } + if (apiBaseUrl) { apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); } else if (host === BITBUCKET_HOST) { diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts new file mode 100644 index 0000000000..22b17d51fa --- /dev/null +++ b/packages/integration/src/bitbucket/core.test.ts @@ -0,0 +1,290 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; +import { BitbucketIntegrationConfig } from './config'; +import { + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; + +describe('bitbucket core', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + describe('getBitbucketRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withToken).headers as any).Authorization, + ).toEqual('Bearer A'); + expect( + (getBitbucketRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withUsernameAndPassword).headers as any) + .Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + (getBitbucketRequestOptions(withoutUsernameAndPassword).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getBitbucketFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, + ), + ).toEqual( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ); + }); + + it('happy path for Bitbucket Server', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=', + ); + }); + }); + + describe('getBitbucketDownloadUrl', () => { + it('add path param if a path is specified for Bitbucket Server', 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(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 result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=main&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param if no path is specified for Bitbucket Server', 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(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 result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse', + config, + ); + + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=main&prefix=backstage-mock', + ); + }); + + it('get by branch for Bitbucket Server', async () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=some-branch&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param for Bitbucket Cloud', async () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.org/backstage/mock/src/master', + config, + ); + expect(result).toEqual( + 'https://bitbucket.org/backstage/mock/get/master.zip', + ); + }); + }); + + describe('getBitbucketDefaultBranch', () => { + it('return default branch for Bitbucket Cloud', async () => { + const repoInfoResponse = { + mainbranch: { + name: 'main', + }, + }; + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoInfoResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.org/backstage/mock/src/main', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server', 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(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) => + 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'); + }); + }); +}); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts new file mode 100644 index 0000000000..8b1d7d6b18 --- /dev/null +++ b/packages/integration/src/bitbucket/core.ts @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 parseGitUrl from 'git-url-parse'; +import { BitbucketIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url A URL pointing to a path + * @param config The relevant provider config + */ +export async function getBitbucketDefaultBranch( + url: string, + config: BitbucketIntegrationConfig, +): Promise { + const { name: repoName, owner: project, resource } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + // 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}/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)); + } + + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + let defaultBranch; + if (isHosted) { + const repoInfo = await response.json(); + defaultBranch = repoInfo.mainbranch.name; + } else { + const { displayId } = await response.json(); + defaultBranch = displayId; + } + if (!defaultBranch) { + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url A URL pointing to a path + * @param config The relevant provider config + */ +export async function getBitbucketDownloadUrl( + url: string, + config: BitbucketIntegrationConfig, +): Promise { + const { + name: repoName, + owner: project, + ref, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + + let branch = ref; + if (!branch) { + branch = await getBitbucketDefaultBranch(url, config); + } + // path will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will download the docs folder and everything below it + const path = filepath ? `&path=${encodeURIComponent(filepath)}` : ''; + const archiveUrl = isHosted + ? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.zip` + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=zip&at=${branch}&prefix=${project}-${repoName}${path}`; + + return archiveUrl; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + * to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getBitbucketFileFetchUrl( + url: string, + config: BitbucketIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (config.host === 'bitbucket.org') { + if (!ref) { + throw new Error('Invalid Bitbucket URL or file path'); + } + return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`; + } + return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getBitbucketRequestOptions( + config: BitbucketIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + } else if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index 897c00d160..124fe4a2a2 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -14,8 +14,15 @@ * limitations under the License. */ +export { BitbucketIntegration } from './BitbucketIntegration'; export { readBitbucketIntegrationConfig, readBitbucketIntegrationConfigs, } from './config'; export type { BitbucketIntegrationConfig } from './config'; +export { + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts new file mode 100644 index 0000000000..9056517f32 --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { GitHubIntegration } from './GitHubIntegration'; + +describe('GitHubIntegration', () => { + it('has a working factory', () => { + const integrations = GitHubIntegration.factory({ + config: new ConfigReader({ + integrations: { + github: [ + { + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }, + ], + }, + }), + }); + 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', + 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 new file mode 100644 index 0000000000..c60ab462c9 --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + GitHubIntegrationConfig, + readGitHubIntegrationConfigs, +} from './config'; + +export class GitHubIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + return basicIntegrations( + configs.map(c => new GitHubIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: GitHubIntegrationConfig) {} + + get type(): string { + return 'github'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): GitHubIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } +} 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.test.ts b/packages/integration/src/github/config.test.ts index d33cffb7be..bcac7dc82c 100644 --- a/packages/integration/src/github/config.test.ts +++ b/packages/integration/src/github/config.test.ts @@ -23,7 +23,7 @@ import { describe('readGitHubIntegrationConfig', () => { function buildConfig(provider: Partial) { - return ConfigReader.fromConfigs([{ context: '', data: provider }]); + return new ConfigReader(provider); } it('reads all values', () => { @@ -80,9 +80,7 @@ describe('readGitHubIntegrationConfigs', () => { function buildConfig( providers: Partial[], ): Config[] { - return providers.map(provider => - ConfigReader.fromConfigs([{ context: '', data: provider }]), - ); + return providers.map(provider => new ConfigReader(provider)); } it('reads all values', () => { diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index f646acc53b..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const GITHUB_HOST = 'github.com'; const GITHUB_API_BASE_URL = 'https://api.github.com'; @@ -57,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; }; /** @@ -71,6 +107,19 @@ 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( + `Invalid GitHub integration config, '${host}' is not a valid host`, + ); + } if (apiBaseUrl) { apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); @@ -84,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/core.test.ts b/packages/integration/src/github/core.test.ts new file mode 100644 index 0000000000..03235acfbb --- /dev/null +++ b/packages/integration/src/github/core.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitHubIntegrationConfig } from './config'; +import { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; + +describe('github core', () => { + describe('getGitHubRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + token: 'A', + }; + const withoutToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + }; + expect( + (getGitHubRequestOptions(withToken).headers as any).Authorization, + ).toEqual('token A'); + expect( + (getGitHubRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + }); + + describe('getGitHubFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getGitHubFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for github api', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for ghe api', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + apiBaseUrl: 'https://ghe.mycompany.net/api/v3', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for github raw', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + rawBaseUrl: 'https://raw.githubusercontent.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', + ); + }); + + it('happy path for ghe raw', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + rawBaseUrl: 'https://ghe.mycompany.net/raw', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'); + }); + }); +}); diff --git a/packages/integration/src/github/core.ts b/packages/integration/src/github/core.ts new file mode 100644 index 0000000000..239692e962 --- /dev/null +++ b/packages/integration/src/github/core.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { GitHubIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://github.com/a/b/blob/branchname/path/to/c.yaml + * to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname + * or: https://raw.githubusercontent.com/a/b/branchname/c.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getGitHubFileFetchUrl( + url: string, + config: GitHubIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + !ref || + (filepathtype !== 'blob' && filepathtype !== 'raw') + ) { + throw new Error('Invalid GitHub URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + if (chooseEndpoint(config) === 'api') { + return `${config.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`; + } + return `${config.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitHubRequestOptions( + config: GitHubIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (chooseEndpoint(config) === 'api') { + headers.Accept = 'application/vnd.github.v3.raw'; + } + if (config.token) { + headers.Authorization = `token ${config.token}`; + } + + return { headers }; +} + +export function chooseEndpoint(config: GitHubIntegrationConfig): 'api' | 'raw' { + if (config.apiBaseUrl && (config.token || !config.rawBaseUrl)) { + return 'api'; + } + return 'raw'; +} diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 2099dd42e3..99d2f56d0f 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -19,3 +19,6 @@ export { readGitHubIntegrationConfigs, } from './config'; export type { GitHubIntegrationConfig } from './config'; +export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; +export { GithubCredentialsProvider } from './GithubCredentialsProvider'; +export { GitHubIntegration } from './GitHubIntegration'; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts new file mode 100644 index 0000000000..6faae3a5e7 --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { GitLabIntegration } from './GitLabIntegration'; + +describe('GitLabIntegration', () => { + it('has a working factory', () => { + const integrations = GitLabIntegration.factory({ + config: new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'h.com', + token: 't', + apiBaseUrl: 'https://h.com/api/v4', + baseUrl: 'https://h.com', + }, + ], + }, + }), + }); + 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', () => { + const integration = new GitLabIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('gitlab'); + expect(integration.title).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts new file mode 100644 index 0000000000..131825edb5 --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + GitLabIntegrationConfig, + readGitLabIntegrationConfigs, +} from './config'; + +export class GitLabIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + return basicIntegrations( + configs.map(c => new GitLabIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: GitLabIntegrationConfig) {} + + get type(): string { + return 'gitlab'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): GitLabIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } +} diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index 9998a530c6..bfa59d001e 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -23,7 +23,7 @@ import { describe('readGitLabIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -31,17 +31,40 @@ describe('readGitLabIntegrationConfig', () => { buildConfig({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com', + baseUrl: 'https://baseurl.for.me/gitlab', }), ); + expect(output).toEqual({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com', + baseUrl: 'https://baseurl.for.me/gitlab', }); }); it('inserts the defaults if missing', () => { - const output = readGitLabIntegrationConfig(buildConfig({})); - expect(output).toEqual({ host: 'gitlab.com' }); + const output = readGitLabIntegrationConfig( + buildConfig({ host: 'gitlab.com' }), + ); + expect(output).toEqual({ + host: 'gitlab.com', + 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', + }); }); it('rejects funky configs', () => { @@ -60,9 +83,7 @@ describe('readGitLabIntegrationConfig', () => { describe('readGitLabIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { @@ -71,12 +92,16 @@ describe('readGitLabIntegrationConfigs', () => { { host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com/api/v4', + baseUrl: 'https://a.com', }, ]), ); expect(output).toContainEqual({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com/api/v4', + baseUrl: 'https://a.com', }); }); @@ -85,6 +110,8 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toEqual([ { host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', }, ]); }); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index 97c948c999..cf717e08d2 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -15,8 +15,10 @@ */ import { Config } from '@backstage/config'; +import { isValidHost, isValidUrl } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; +const GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'; /** * The configuration parameters for a single GitLab integration. @@ -27,12 +29,31 @@ export type GitLabIntegrationConfig = { */ host: string; + /** + * The base URL of the API of this provider, e.g. "https://gitlab.com/api/v4", + * with no trailing slash. + * + * May be omitted specifically for GitLab; then it will be deduced. + * + * The API will always be preferred if both its base URL and a token are + * present. + */ + apiBaseUrl: string; + /** * The authorization token to use for requests this provider. * * 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; }; /** @@ -43,9 +64,42 @@ export type GitLabIntegrationConfig = { export function readGitLabIntegrationConfig( config: Config, ): GitLabIntegrationConfig { - const host = config.getOptionalString('host') ?? GITLAB_HOST; + const host = config.getString('host'); + let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); - return { host, token }; + let baseUrl = config.getOptionalString('baseUrl'); + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (host === GITLAB_HOST) { + apiBaseUrl = GITLAB_API_BASE_URL; + } + + if (baseUrl) { + baseUrl = baseUrl.replace(/\/+$/, ''); + } else { + baseUrl = `https://${host}`; + } + + if (host.includes(':')) { + throw new Error( + `Invalid GitLab integration config, host '${host}' should just be the host name (e.g. "github.com"), not a URL`, + ); + } else if (!isValidHost(host)) { + throw new Error( + `Invalid GitLab integration config, '${host}' is not a valid host`, + ); + } else if (!apiBaseUrl || !isValidUrl(apiBaseUrl)) { + throw new Error( + `Invalid GitLab integration config, '${apiBaseUrl}' is not a valid apiBaseUrl`, + ); + } else if (!isValidUrl(baseUrl)) { + throw new Error( + `Invalid GitLab integration config, '${baseUrl}' is not a valid baseUrl`, + ); + } + + return { host, token, apiBaseUrl, baseUrl }; } /** @@ -63,7 +117,11 @@ 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, + baseUrl: `https://${GITLAB_HOST}`, + }); } return result; diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts new file mode 100644 index 0000000000..8e48871423 --- /dev/null +++ b/packages/integration/src/gitlab/core.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { GitLabIntegrationConfig } from './config'; +import { getGitLabFileFetchUrl } from './core'; + +const worker = setupServer(); + +describe('gitlab core', () => { + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + afterEach(() => worker.resetHandlers()); + + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + ); + }); + + const configWithToken: GitLabIntegrationConfig = { + host: 'g.com', + token: '0123456789', + apiBaseUrl: '', + baseUrl: '', + }; + + const configWithNoToken: GitLabIntegrationConfig = { + host: 'g.com', + apiBaseUrl: '', + baseUrl: '', + }; + + describe('getGitLabFileFetchUrl', () => { + it.each([ + // Project URLs + { + config: configWithNoToken, + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithNoToken, + // Works with non URI encoded link + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file with spaces.yaml', + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile%20with%20spaces.yaml/raw?ref=branch', + }, + { + config: configWithNoToken, + url: + '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%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithToken, + url: + '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%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithNoToken, + url: + '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%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', + }, + // Raw URLs + { + config: configWithNoToken, + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + result: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }, + ])('should handle happy path %#', async ({ config, url, result }) => { + await expect(getGitLabFileFetchUrl(url, config)).resolves.toBe(result); + }); + }); +}); diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts new file mode 100644 index 0000000000..66b6733f56 --- /dev/null +++ b/packages/integration/src/gitlab/core.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitLabIntegrationConfig } from './config'; +import fetch from 'cross-fetch'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://gitlab.example.com/a/b/blob/master/c.yaml + * to: https://gitlab.example.com/a/b/raw/master/c.yaml + * -or- + * from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + * to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export async function getGitLabFileFetchUrl( + url: string, + config: GitLabIntegrationConfig, +): Promise { + // TODO(Rugvip): From the old GitlabReaderProcessor; used + // the existence of /-/blob/ to switch the logic. Don't know if this + // makes sense and it might require some more work. + if (url.includes('/-/blob/')) { + const projectID = await getProjectId(url, config); + return buildProjectUrl(url, projectID).toString(); + } + return buildRawUrl(url).toString(); +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitLabRequestOptions( + config: GitLabIntegrationConfig, +): RequestInit { + const { token = '' } = config; + return { + headers: { + 'PRIVATE-TOKEN': token, + }, + }; +} + +// Converts +// from: https://gitlab.example.com/a/b/blob/master/c.yaml +// to: https://gitlab.example.com/a/b/raw/master/c.yaml +export function buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + repoName, + blobKeyword, + ...restOfPath + ] = url.pathname.split('/'); + + if ( + empty !== '' || + userOrOrg === '' || + repoName === '' || + blobKeyword !== 'blob' || + !restOfPath.join('/').match(/\.yaml$/) + ) { + throw new Error('Wrong GitLab URL'); + } + + // Replace 'blob' with 'raw' + url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join('/'); + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Converts +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch +export function buildProjectUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndFilePath = url.pathname.split('/-/blob/')[1]; + const [branch, ...filePath] = branchAndFilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(decodeURIComponent(filePath.join('/'))), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Convert +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: The project ID that corresponds to the URL +export async function getProjectId( + target: string, + config: GitLabIntegrationConfig, +): Promise { + const url = new URL(target); + + if (!url.pathname.includes('/-/blob/')) { + throw new Error('Please provide full path to yaml file from GitLab'); + } + + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Convert + // to: https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + getGitLabRequestOptions(config), + ); + const data = await response.json(); + + 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/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 0801914fd4..8886e0e3ce 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -19,3 +19,5 @@ export { readGitLabIntegrationConfigs, } from './config'; export type { GitLabIntegrationConfig } from './config'; +export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; +export { GitLabIntegration } from './GitLabIntegration'; diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts new file mode 100644 index 0000000000..2899f2ed98 --- /dev/null +++ b/packages/integration/src/helpers.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defaultScmResolveUrl, isValidHost } from './helpers'; + +describe('isValidHost', () => { + it.each([ + ['example.com', true], + ['foo', true], + ['foo:1', true], + ['foo:10000', true], + ['foo.bar', true], + ['foo.bar.baz', true], + ['1.2.3.4', true], + ['[::]', true], + ['[::1]', true], + ['[1:2:3:4:5:6:7:8]', true], + ['1.2.3.4.5.6.7.8', true], + ['https://example.com', false], + ['foo:100000', false], + ['FOO', false], + ['Foo', false], + ['foo/bar', false], + ['//foo', false], + ['foo:bar', false], + ['foo?', false], + ['foo?bar', false], + ['foo#', false], + ['foo#bar', false], + ['::', false], + ['::1', false], + ['1:2:3:4:5:6:7:8', false], + ['???????', false], + ['€&()=)&(', false], + ['höst', false], + ['πœπœfiπœ', false], + ])('Should check whether %s is a valid host', (str, expected) => { + expect(isValidHost(str)).toBe(expected); + }); +}); + +describe('defaultScmResolveUrl', () => { + it('works for relative paths and retains query params', () => { + expect( + defaultScmResolveUrl({ + url: './b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml', + ); + + expect( + defaultScmResolveUrl({ + url: './b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml?at=master', + ); + + expect( + defaultScmResolveUrl({ + url: 'b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml', + ); + }); + + it('works for absolute paths and retains query params', () => { + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml', + ); + + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml?at=master', + ); + }); + + it('works for full urls and throws away query params', () => { + expect( + defaultScmResolveUrl({ + url: 'https://b.com/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe('https://b.com/b.yaml'); + }); +}); diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts new file mode 100644 index 0000000000..c09f808011 --- /dev/null +++ b/packages/integration/src/helpers.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { ScmIntegration, ScmIntegrationsGroup } from './types'; + +/** Checks whether the given argument is a valid URL hostname */ +export function isValidHost(host: string): boolean { + const check = new URL('http://example.com'); + check.host = host; + return check.host === host; +} + +/** Checks whether the given argument is a valid URL */ +export function isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} + +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); + }, + }; +} + +/** + * Default implementation of ScmIntegration.resolveUrl, that only works with + * URL pathname based providers. + */ +export function defaultScmResolveUrl(options: { + url: string; + base: string; +}): string { + const { url, base } = options; + + // If it is a fully qualified URL - then return it verbatim + try { + // eslint-disable-next-line no-new + new URL(url); + return url; + } catch { + // ignore intentionally + } + + let updated: URL; + + if (url.startsWith('/')) { + // If it is an absolute path, move relative to the repo root + const { filepath } = parseGitUrl(base); + updated = new URL(base); + const repoRootPath = updated.pathname + .substring(0, updated.pathname.length - filepath.length) + .replace(/\/+$/, ''); + updated.pathname = `${repoRootPath}${url}`; + } else { + // For relative URLs, just let the default URL constructor handle the + // resolving. Note that this essentially will treat the last segment of the + // base as a file - NOT a folder - unless the url ends in a slash. + updated = new URL(url, base); + } + + updated.search = new URL(base).search; + return updated.toString(); +} diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index bfed81824f..c39608fe8e 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,3 +18,6 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; +export { defaultScmResolveUrl } from './helpers'; +export { ScmIntegrations } from './ScmIntegrations'; +export type { ScmIntegration, ScmIntegrationRegistry } from './types'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts new file mode 100644 index 0000000000..c63a323dc3 --- /dev/null +++ b/packages/integration/src/types.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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 interface ScmIntegration { + /** + * The type of integration, e.g. "github". + */ + type: string; + + /** + * A human readable title for the integration, that can be shown to users to + * differentiate between different integrations. + */ + title: string; + + /** + * Resolves an absolute or relative URL in relation to a base URL. + * + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. + * + * @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; +} + +/** + * Encapsulates several integrations, that are all of the same type. + */ +export interface ScmIntegrationsGroup { + /** + * Lists all registered integrations of this type. + */ + list(): T[]; + + /** + * Fetches an integration of this type by URL. + * + * @param url A URL that matches a registered integration of this type + */ + byUrl(url: string | URL): T | undefined; + + /** + * 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; +} + +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; + + /** + * Resolves an absolute or relative URL in relation to a base URL. + * + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. + * + * @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; +}) => ScmIntegrationsGroup; diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 878256ff5c..128ccfc67a 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -26,7 +26,7 @@ import { const builder = ApiRegistry.builder(); -builder.add(configApiRef, ConfigReader.fromConfigs([])); +builder.add(configApiRef, new ConfigReader({})); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 136b3945da..77bafdd231 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -22,7 +22,7 @@ module.exports = { // Remove the default babel-loader for js files, we're using sucrase instead const [jsLoader] = config.module.rules.splice(0, 1); - if (jsLoader.use[0].loader !== 'babel-loader') { + if (!jsLoader.use[0].loader.includes('babel-loader')) { throw new Error( `Unexpected loader removed from storybook config, ${jsLoader.use[0].loader}`, ); diff --git a/packages/storybook/README.md b/packages/storybook/README.md index 38708afe4b..3e88b54db0 100644 --- a/packages/storybook/README.md +++ b/packages/storybook/README.md @@ -1,7 +1,7 @@ # storybook -This package provides a storybook build for Backstage. See [http://backstage.io/storybook](http://http://backstage.io/storybook) +This package provides a Storybook build for Backstage. See https://backstage.io/storybook/. ## Why is this not part of `@backstage/core`? -This separate storybook package exists because of dependency conflicts with `@backstage/cli`. It uses nohoist to avoid the conflicts, and since you can only use that in private packages it has to be separated out of `@backstage/core`. +This separate storybook package exists because of dependency conflicts with `@backstage/cli`. It uses `nohoist` to avoid the conflicts, and since you can only use that in private packages it has to be separated out of `@backstage/core`. diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f143596a9a..01aabac0de 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -17,11 +17,11 @@ "@backstage/theme": "^0.2.0" }, "devDependencies": { - "@storybook/addon-actions": "^6.0.21", - "@storybook/addon-links": "^6.0.21", - "@storybook/addon-storysource": "^6.0.21", - "@storybook/addons": "^6.0.21", - "@storybook/react": "^6.0.21", - "storybook-dark-mode": "^1.0.2" + "@storybook/addon-actions": "^6.1.11", + "@storybook/addon-links": "^6.1.11", + "@storybook/addon-storysource": "^6.1.11", + "@storybook/addons": "^6.1.11", + "@storybook/react": "^6.1.11", + "storybook-dark-mode": "^1.0.3" } } diff --git a/plugins/sentry-backend/.eslintrc.js b/packages/techdocs-common/.eslintrc.js similarity index 100% rename from plugins/sentry-backend/.eslintrc.js rename to packages/techdocs-common/.eslintrc.js diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md new file mode 100644 index 0000000000..60a10c924e --- /dev/null +++ b/packages/techdocs-common/CHANGELOG.md @@ -0,0 +1,270 @@ +# @backstage/techdocs-common + +## 0.4.2 + +### Patch Changes + +- 2499f6cde: Add support for assuming role in AWS integrations +- 1e4ddd71d: Fix AWS, GCS and Azure publisher to work on Windows. +- Updated dependencies [bad21a085] +- Updated dependencies [a1f5e6545] + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.4.1 + +### Patch Changes + +- fb28da212: Switched to using `'x-access-token'` for authenticating Git over HTTPS towards GitHub. +- 26e143e60: After TechDocs generate step, insert build timestamp to techdocs_metadata.json +- c6655413d: Improved error reporting in AzureBlobStorage to surface errors when fetching metadata and uploading files fails. +- 44414239f: Pass user and group ID when invoking docker container. When TechDocs invokes Docker, docker could be run as a `root` user which results in generation of files by applications run by non-root user (e.g. TechDocs) will not have access to modify. This PR passes in current user and group ID to docker so that the file permissions of the generated files and folders are correct. +- b0a41c707: Add etag of the prepared file tree to techdocs_metadata.json in the storage +- Updated dependencies [16fb1d03a] +- Updated dependencies [491f3a0ec] +- Updated dependencies [491f3a0ec] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] + - @backstage/backend-common@0.5.4 + - @backstage/integration@0.5.0 + +## 0.4.0 + +### Minor Changes + +- 08142b256: URL Preparer will now use proper etag based caching introduced in https://github.com/backstage/backstage/pull/4120. Previously, builds used to be cached for 30 minutes. + +### Patch Changes + +- 77ad0003a: Revert AWS SDK version to v2 +- 08142b256: TechDocs will throw warning in backend logs when legacy git preparer or dir preparer is used to preparer docs. Migrate to URL Preparer by updating `backstage.io/techdocs-ref` annotation to be prefixed with `url:`. + Detailed docs are here https://backstage.io/docs/features/techdocs/how-to-guides#how-to-use-url-reader-in-techdocs-prepare-step + See benefits and reason for doing so https://github.com/backstage/backstage/issues/4409 +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + +## 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 + +- 8804e8981: Using @backstage/integration package for GitHub/GitLab/Azure tokens and request options. + + Most probably you do not have to make any changes in the app because of this change. + However, if you are using the `DirectoryPreparer` or `CommonGitPreparer` exported by + `@backstage/techdocs-common` package, you now need to add pass in a `config` (from `@backstage/config`) + instance as argument. + + ``` + + const directoryPreparer = new DirectoryPreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + + const directoryPreparer = new DirectoryPreparer(config, logger); + const commonGitPreparer = new CommonGitPreparer(config, logger); + ``` + +## 0.3.0 + +### Minor Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +## 0.2.0 + +### Minor Changes + +- dae4f3983: _Breaking changes_ + + 1. Added option to use Google Cloud Storage as a choice to store the static generated files for TechDocs. + It can be configured using `techdocs.publisher.type` option in `app-config.yaml`. + Step-by-step guide to configure GCS is available here https://backstage.io/docs/features/techdocs/using-cloud-storage + Set `techdocs.publisher.type` to `'local'` if you want to continue using local filesystem to store TechDocs files. + + 2. `techdocs.builder` is now required and can be set to `'local'` or `'external'`. (Set it to `'local'` for now, since CI/CD build + workflow for TechDocs will be available soon (in few weeks)). + 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. + If builder is set to `'external'`, `techdocs-backend` will only fetch the docs and will NOT try to generate and publish. In this case of `'external'`, + we assume that docs are being built in the CI/CD pipeline of the repository. + TechDocs will not assume a default value for `techdocs.builder`. It is better to explicitly define it in the `app-config.yaml`. + + 3. When configuring TechDocs in your backend, there is a difference in how a new publisher is created. + + ``` + --- const publisher = new LocalPublish(logger, discovery); + +++ const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. + + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + + 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs + to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - + app should only import `@backstage/plugin-techdocs` and `@backstage/plugin-techdocs-backend`. + + _Patch changes_ + + 1. See all of TechDocs config options and its documentation https://backstage.io/docs/features/techdocs/configuration + + 2. Logic about serving static files and metadata retrieval have been abstracted away from the router in `techdocs-backend` to the instance of publisher. + + 3. Removed Material UI Spinner from TechDocs header. Spinners cause unnecessary UX distraction. + Case 1 (when docs are built and are to be served): Spinners appear for a split second before the name of site shows up. This unnecessarily distracts eyes because spinners increase the size of the Header. A dot (.) would do fine. Definitely more can be done. + Case 2 (when docs are being generated): There is already a linear progress bar (which is recommended in Storybook). + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 diff --git a/packages/techdocs-common/README.md b/packages/techdocs-common/README.md new file mode 100644 index 0000000000..e4889d6f79 --- /dev/null +++ b/packages/techdocs-common/README.md @@ -0,0 +1,49 @@ +# @backstage/techdocs-common + +Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli + +This package is used by `techdocs-backend` to serve docs from different types of publishers (Google GCS, Local, etc.). +It is also used to build docs and publish them to storage, by both `techdocs-backend` and `techdocs-cli`. + +## Usage + +Create a preparer instance from the [preparers available](/packages/techdocs-common/src/stages/prepare) at which takes an Entity instance. +Run the [docs generator](/packages/techdocs-common/src/stages/generate) on the prepared directory. +Publish the generated directory files to a [storage](/packages/techdocs-common/src/stages/publish) of your choice. + +Example: + +```js +async () => { + const preparedDir = await preparer.prepare(entity); + + const parsedLocationAnnotation = getLocationForEntity(entity); + const { resultDir } = await generator.run({ + directory: preparedDir, + dockerClient: dockerClient, + parsedLocationAnnotation, + }); + + await publisher.publish({ + entity: entity, + directory: resultDir, + }); +}; +``` + +## Features + +Currently the build process is split up in these three stages. + +- Preparers +- Generators +- Publishers + +Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/backstage/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. + +Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. + +Publishers gets a folder path from the generator and publish it to your storage solution. Read documentation to know more about configuring storage solutions. +http://backstage.io/docs/features/techdocs/configuration + +Any of these can be extended. We want to extend our support to most of the storage providers (Publishers) and source code host providers (Preparers). 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..4fc1e4c374 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + BlobUploadCommonResponse, + ContainerGetPropertiesResponse, +} from '@azure/storage-blob'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +/** + * @param sourceFile Relative path to entity root dir. Contains either / or \ as file separator + * depending upon the OS. + */ +const checkFileExists = async (sourceFile: string): Promise => { + // sourceFile will always have / as file separator irrespective of OS since Azure expects /. + // Normalize sourceFile to OS specific path before checking if file exists. + const relativeFilePath = sourceFile.split(path.posix.sep).join(path.sep); + const filePath = path.join(rootDir, sourceFile); + + try { + await fs.access(filePath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } +}; + +export class BlockBlobClient { + private readonly blobName; + + constructor(blobName: string) { + this.blobName = blobName; + } + + uploadFile(source: string): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 200, + headers: {} as any, + }, + }); + } + + exists() { + return checkFileExists(this.blobName); + } +} + +class BlockBlobClientFailUpload extends BlockBlobClient { + uploadFile(source: string): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 500, + headers: {} as any, + }, + }); + } +} + +export class ContainerClient { + private readonly containerName; + + constructor(containerName: string) { + this.containerName = containerName; + } + + getProperties(): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 200, + headers: {} as any, + parsedHeaders: {}, + }, + }); + } + + getBlockBlobClient(blobName: string) { + return new BlockBlobClient(blobName); + } +} + +class ContainerClientFailGetProperties extends ContainerClient { + getProperties(): Promise { + return Promise.resolve({ + _response: { + request: { + url: `https://example.blob.core.windows.net`, + } as any, + status: 404, + headers: {} as any, + parsedHeaders: {}, + }, + }); + } +} + +class ContainerClientFailUpload extends ContainerClient { + getBlockBlobClient(blobName: string) { + return new BlockBlobClientFailUpload(blobName); + } +} + +export class BlobServiceClient { + private readonly url; + private readonly credential; + + constructor(url: string, credential?: StorageSharedKeyCredential) { + this.url = url; + this.credential = credential; + } + + getContainerClient(containerName: string) { + if (containerName === 'bad_container') { + return new ContainerClientFailGetProperties(containerName); + } + if (this.credential.accountName === 'failupload') { + return new ContainerClientFailUpload(containerName); + } + 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 new file mode 100644 index 0000000000..264852d31c --- /dev/null +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventEmitter } from 'events'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +type storageOptions = { + keyFilename?: string; +}; + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +/** + * @param sourceFile Absolute path. Contains either / or \ as file separator depending upon the OS. + */ +const checkFileExists = async (sourceFile: string): Promise => { + // sourceFile will always have / as file separator irrespective of OS since GCS expects /. + // Normalize sourceFile to OS specific path before checking if file exists. + const filePath = sourceFile.split(path.posix.sep).join(path.sep); + + try { + await fs.access(filePath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } +}; + +class GCSFile { + private readonly localFilePath: string; + + constructor(private readonly destinationFilePath: string) { + this.destinationFilePath = destinationFilePath; + this.localFilePath = path.join(rootDir, this.destinationFilePath); + } + + exists() { + return new Promise(async (resolve, reject) => { + if (await checkFileExists(this.localFilePath)) { + resolve([true]); + } else { + reject(); + } + }); + } + + createReadStream() { + const emitter = new EventEmitter(); + process.nextTick(() => { + if (fs.existsSync(this.localFilePath)) { + emitter.emit('data', Buffer.from(fs.readFileSync(this.localFilePath))); + emitter.emit('end'); + } else { + emitter.emit( + 'error', + new Error(`The file ${this.localFilePath} does not exist !`), + ); + } + }); + return emitter; + } +} + +class Bucket { + private readonly bucketName; + + constructor(bucketName: string) { + this.bucketName = bucketName; + } + + getMetadata() { + return new Promise(resolve => { + resolve(''); + }); + } + + upload(source: string, { destination }) { + return new Promise(async (resolve, reject) => { + if (await checkFileExists(source)) { + resolve({ source, destination }); + } else { + reject(`Source file ${source} does not exist.`); + } + }); + } + + file(destinationFilePath: string) { + return new GCSFile(destinationFilePath); + } +} + +export class Storage { + private readonly keyFilename; + + constructor(options: storageOptions) { + this.keyFilename = options.keyFilename; + } + + bucket(bucketName) { + return new Bucket(bucketName); + } +} diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts new file mode 100644 index 0000000000..4717feda9b --- /dev/null +++ b/packages/techdocs-common/__mocks__/aws-sdk.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 type { S3 as S3Types } from 'aws-sdk'; +import { EventEmitter } from 'events'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + +/** + * @param Key Relative path to entity root dir. Contains either / or \ as file separator + * depending upon the OS. + */ +const checkFileExists = async (Key: string): Promise => { + // Key will always have / as file separator irrespective of OS since S3 expects /. + // Normalize Key to OS specific path before checking if file exists. + const relativeFilePath = Key.split(path.posix.sep).join(path.sep); + const filePath = path.join(rootDir, Key); + + try { + await fs.access(filePath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } +}; + +export class S3 { + private readonly options; + + constructor(options: S3Types.ClientConfiguration) { + this.options = options; + } + + headObject({ Key }: { Key: string }) { + return { + promise: async () => { + if (!(await checkFileExists(Key))) { + throw new Error('File does not exist'); + } + }, + }; + } + + getObject({ Key }: { Key: string }) { + const filePath = path.join(rootDir, Key); + return { + promise: () => checkFileExists(filePath), + createReadStream: () => { + const emitter = new EventEmitter(); + process.nextTick(() => { + if (fs.existsSync(filePath)) { + emitter.emit('data', Buffer.from(fs.readFileSync(filePath))); + emitter.emit('end'); + } else { + emitter.emit( + 'error', + new Error(`The file ${filePath} does not exist !`), + ); + } + }); + return emitter; + }, + }; + } + + headBucket() { + return new Promise(resolve => { + resolve(''); + }); + } + + upload({ Key }: { Key: string }) { + return { + promise: () => + new Promise(async (resolve, reject) => { + if (!(await checkFileExists(Key))) { + reject(`The file ${Key} does not exist`); + } else { + resolve(''); + } + }), + }; + } +} + +export default { + S3, +}; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json new file mode 100644 index 0000000000..c8ae633c97 --- /dev/null +++ b/packages/techdocs-common/package.json @@ -0,0 +1,76 @@ +{ + "name": "@backstage/techdocs-common", + "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", + "version": "0.4.2", + "main": "src/index.ts", + "types": "src/index.ts", + "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": "packages/techdocs-common" + }, + "keywords": [ + "techdocs", + "backstage" + ], + "license": "Apache-2.0", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@azure/identity": "^1.2.2", + "@azure/storage-blob": "^12.4.0", + "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/integration": "^0.5.0", + "@google-cloud/storage": "^5.6.0", + "@types/dockerode": "^3.2.1", + "@types/express": "^4.17.6", + "aws-sdk": "^2.840.0", + "cross-fetch": "^3.0.6", + "dockerode": "^3.2.1", + "express": "^4.17.1", + "fs-extra": "^9.0.1", + "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.6.2", + "@types/fs-extra": "^9.0.5", + "@types/git-url-parse": "^9.0.0", + "@types/js-yaml": "^3.12.5", + "@types/mime-types": "^2.1.0", + "@types/mock-fs": "^4.13.0", + "@types/recursive-readdir": "^2.2.0" + }, + "jest": { + "roots": [ + ".." + ] + } +} diff --git a/plugins/techdocs-backend/src/default-branch.ts b/packages/techdocs-common/src/default-branch.ts similarity index 73% rename from plugins/techdocs-backend/src/default-branch.ts rename to packages/techdocs-common/src/default-branch.ts index 1090fcb3d3..c0ec63af10 100644 --- a/plugins/techdocs-backend/src/default-branch.ts +++ b/packages/techdocs-common/src/default-branch.ts @@ -16,13 +16,17 @@ import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; import { Config } from '@backstage/config'; -import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; import { - getAzureHostToken, + getGitHubRequestOptions, + getGitLabRequestOptions, + getAzureRequestOptions, +} from '@backstage/integration'; +import { getGitHost, - getGithubHostToken, - getGitlabHostToken, getGitRepoType, + getGitHubIntegrationConfig, + getGitLabIntegrationConfig, + getAzureIntegrationConfig, } from './git-auth'; interface IGitlabBranch { @@ -50,19 +54,19 @@ interface IGitlabBranch { } function getGithubApiUrl(config: Config, url: string): URL { - const { protocol, owner, name } = parseGitUrl(url); + const { resource, owner, name } = parseGitUrl(url); const providerConfigs = config.getOptionalConfigArray('integrations.github') ?? []; - // TODO: Maybe we need to filter by host in the array, not sure about GHE - const targetProviderConfig = providerConfigs[0]; + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === resource, + ); const apiBaseUrl = - targetProviderConfig?.getOptionalString('integrations.github.apiBaseUrl') ?? - 'api.github.com'; + hostConfig[0]?.getOptionalString('apiBaseUrl') ?? 'https://api.github.com'; const apiRepos = 'repos'; - return new URL(`${protocol}://${apiBaseUrl}/${apiRepos}/${owner}/${name}`); + return new URL(`${apiBaseUrl}/${apiRepos}/${owner}/${name}`); } function getGitlabApiUrl(url: string): URL { @@ -86,62 +90,15 @@ function getAzureApiUrl(url: string): URL { ); } -function getGithubRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; - - const token = getGithubHostToken(config, host); - - if (token) { - headers.Authorization = `token ${token}`; - } - - return { - headers, - }; -} - -function getGitlabRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = { - 'PRIVATE-TOKEN': '', - }; - - const token = getGitlabHostToken(config, host); - if (token) { - headers['PRIVATE-TOKEN'] = token; - } - - return { - headers, - }; -} - -function getAzureRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = {}; - - const token = getAzureHostToken(config, host); - - if (token !== '') { - headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString( - 'base64', - )}`; - } - - const requestOptions: RequestInit = { - headers, - }; - - return requestOptions; -} - async function getGithubDefaultBranch( repositoryUrl: string, config: Config, ): Promise { const path = getGithubApiUrl(config, repositoryUrl).toString(); const host = getGitHost(repositoryUrl); - const options = getGithubRequestOptions(config, host); + + const integrationConfig = getGitHubIntegrationConfig(config, host); + const options = getGitHubRequestOptions(integrationConfig); try { const raw = await fetch(path, options); @@ -169,9 +126,10 @@ async function getGitlabDefaultBranch( config: Config, ): Promise { const path = getGitlabApiUrl(repositoryUrl).toString(); + const host = getGitHost(repositoryUrl); - const gitlabHost = getGitHost(repositoryUrl); - const options = getGitlabRequestOptions(config, gitlabHost); + const integrationConfig = getGitLabIntegrationConfig(config, host); + const options = getGitLabRequestOptions(integrationConfig); try { const raw = await fetch(path, options); @@ -203,7 +161,9 @@ async function getAzureDefaultBranch( ): Promise { const path = getAzureApiUrl(repositoryUrl).toString(); const host = getGitHost(repositoryUrl); - const options = getAzureRequestOptions(config, host); + + const integrationConfig = getAzureIntegrationConfig(config, host); + const options = getAzureRequestOptions(integrationConfig); try { const urlResponse = await fetch(path, options); @@ -235,12 +195,8 @@ async function getAzureDefaultBranch( export const getDefaultBranch = async ( repositoryUrl: string, + config: Config, ): Promise => { - // TODO(Rugvip): Config should not be loaded here, pass it in instead - const config = await loadBackendConfig({ - logger: getRootLogger(), - argv: process.argv, - }); const type = getGitRepoType(repositoryUrl); try { diff --git a/packages/techdocs-common/src/git-auth.ts b/packages/techdocs-common/src/git-auth.ts new file mode 100644 index 0000000000..b58afae490 --- /dev/null +++ b/packages/techdocs-common/src/git-auth.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import parseGitUrl from 'git-url-parse'; +import { Config } from '@backstage/config'; +import { + readGitHubIntegrationConfigs, + readGitLabIntegrationConfigs, + readAzureIntegrationConfigs, + GitHubIntegrationConfig, + GitLabIntegrationConfig, + AzureIntegrationConfig, +} from '@backstage/integration'; + +export function getGitHost(url: string): string { + const { resource } = parseGitUrl(url); + return resource; +} + +export function getGitRepoType(url: string): string { + const typeMapping = [ + { url: /github*/g, type: 'github' }, + { url: /gitlab*/g, type: 'gitlab' }, + { url: /azure*/g, type: 'azure/api' }, + ]; + + const type = typeMapping.filter(item => item.url.test(url))[0]?.type; + + return type; +} + +export const getGitHubIntegrationConfig = ( + config: Config, + host: string, +): GitHubIntegrationConfig => { + const allGitHubConfigs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + const gitHubIntegrationConfig = allGitHubConfigs.find(v => v.host === host); + if (!gitHubIntegrationConfig) { + throw new Error(`Unable to locate GitHub integration for the host ${host}`); + } + return gitHubIntegrationConfig; +}; + +export const getGitLabIntegrationConfig = ( + config: Config, + host: string, +): GitLabIntegrationConfig => { + const allGitLabConfigs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + const gitLabIntegrationConfig = allGitLabConfigs.find(v => v.host === host); + if (!gitLabIntegrationConfig) { + throw new Error(`Unable to locate GitLab integration for the host ${host}`); + } + return gitLabIntegrationConfig; +}; + +export const getAzureIntegrationConfig = ( + config: Config, + host: string, +): AzureIntegrationConfig => { + const allAzureIntegrationConfig = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + const azureIntegrationConfig = allAzureIntegrationConfig.find( + v => v.host === host, + ); + if (!azureIntegrationConfig) { + throw new Error(`Unable to locate Azure integration for the host ${host}`); + } + return azureIntegrationConfig; +}; + +export const getTokenForGitRepo = async ( + repositoryUrl: string, + config: Config, +): Promise => { + const host = getGitHost(repositoryUrl); + const type = getGitRepoType(repositoryUrl); + + try { + switch (type) { + case 'github': + return getGitHubIntegrationConfig(config, host).token; + case 'gitlab': + return getGitLabIntegrationConfig(config, host).token; + case 'azure/api': + return getAzureIntegrationConfig(config, host).token; + default: + throw new Error('Failed to get repository type'); + } + } catch (error) { + throw error; + } +}; diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts new file mode 100644 index 0000000000..6520f0f8e4 --- /dev/null +++ b/packages/techdocs-common/src/helpers.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ReadTreeResponse, + SearchResponse, + UrlReader, +} from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { Readable } from 'stream'; +import { + getDocFilesFromRepository, + getLocationForEntity, + parseReferenceAnnotation, +} from './helpers'; + +const entityBase: Entity = { + metadata: { + namespace: 'default', + name: 'mytestcomponent', + description: 'A component for testing', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'documentation', + lifecycle: 'experimental', + owner: 'testuser', + }, +}; + +const metadataBase = { + namespace: 'default', + name: 'mytestcomponent', + description: 'A component for testing', +}; + +const goodAnnotation = { + annotations: { + 'backstage.io/techdocs-ref': + 'url:https://github.com/backstage/backstage/blob/master/subfolder/', + }, +}; + +const mockEntityWithAnnotation: Entity = { + ...entityBase, + ...{ + metadata: { + ...metadataBase, + ...goodAnnotation, + }, + }, +}; + +const badAnnotation = { + annotations: { + 'backstage.io/techdocs-ref': 'bad-annotation', + }, +}; + +const mockEntityWithBadAnnotation: Entity = { + ...entityBase, + ...{ + metadata: { + ...metadataBase, + ...badAnnotation, + }, + }, +}; + +describe('parseReferenceAnnotation', () => { + it('should parse annotation', () => { + const parsedLocationAnnotation = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + mockEntityWithAnnotation, + ); + expect(parsedLocationAnnotation.type).toBe('url'); + expect(parsedLocationAnnotation.target).toBe( + 'https://github.com/backstage/backstage/blob/master/subfolder/', + ); + }); + + it('should throw error without annotation', () => { + expect(() => { + parseReferenceAnnotation('backstage.io/techdocs-ref', entityBase); + }).toThrow(/No location annotation/); + }); + + it('should throw error with bad annotation', () => { + expect(() => { + parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + mockEntityWithBadAnnotation, + ); + }).toThrow(/Failure to parse/); + }); +}); + +describe('getLocationForEntity', () => { + it('should get location for entity', () => { + const parsedLocationAnnotation = getLocationForEntity( + mockEntityWithAnnotation, + ); + expect(parsedLocationAnnotation.type).toBe('url'); + expect(parsedLocationAnnotation.target).toBe( + 'https://github.com/backstage/backstage/blob/master/subfolder/', + ); + }); +}); + +describe('getDocFilesFromRepository', () => { + it('should read a remote directory using UrlReader.readTree', async () => { + class MockUrlReader implements UrlReader { + async read() { + return Buffer.from('mock'); + } + + async readTree(): Promise { + return { + dir: async () => { + return '/tmp/testfolder'; + }, + files: async () => { + return []; + }, + archive: async () => { + return Readable.from(''); + }, + etag: 'etag123abc', + }; + } + + async search(): Promise { + return { + etag: '', + files: [], + }; + } + } + + const output = await getDocFilesFromRepository( + new MockUrlReader(), + mockEntityWithAnnotation, + ); + + expect(output.preparedDir).toBe('/tmp/testfolder'); + expect(output.etag).toBe('etag123abc'); + }); +}); diff --git a/plugins/techdocs-backend/src/helpers.ts b/packages/techdocs-common/src/helpers.ts similarity index 64% rename from plugins/techdocs-backend/src/helpers.ts rename to packages/techdocs-common/src/helpers.ts index fcf5a4f194..ec58bb7b40 100644 --- a/plugins/techdocs-backend/src/helpers.ts +++ b/packages/techdocs-common/src/helpers.ts @@ -14,21 +14,17 @@ * limitations under the License. */ +import { Git, InputError, UrlReader } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import fs from 'fs-extra'; +import parseGitUrl from 'git-url-parse'; import os from 'os'; import path from 'path'; -import parseGitUrl from 'git-url-parse'; -import NodeGit, { Clone, Repository } from 'nodegit'; -import fs from 'fs-extra'; +import { Logger } from 'winston'; import { getDefaultBranch } from './default-branch'; import { getGitRepoType, getTokenForGitRepo } from './git-auth'; -import { Entity } from '@backstage/catalog-model'; -import { InputError, UrlReader } from '@backstage/backend-common'; -import { RemoteProtocol } from './techdocs/stages/prepare/types'; -import { Logger } from 'winston'; - -// Enables core.longpaths on windows to prevent crashing when checking out repos with long foldernames and/or deep nesting -// @ts-ignore -NodeGit.Libgit2.opts(28, 1); +import { PreparerResponse, RemoteProtocol } from './stages/prepare/types'; export type ParsedLocationAnnotation = { type: RemoteProtocol; @@ -94,6 +90,7 @@ export const getLocationForEntity = ( export const getGitRepositoryTempFolder = async ( repositoryUrl: string, + config: Config, ): Promise => { const parsedGitLocation = parseGitUrl(repositoryUrl); // removes .git from git location path @@ -102,6 +99,7 @@ export const getGitRepositoryTempFolder = async ( if (!parsedGitLocation.ref) { parsedGitLocation.ref = await getDefaultBranch( parsedGitLocation.toString('https'), + config, ); } @@ -118,23 +116,68 @@ export const getGitRepositoryTempFolder = async ( export const checkoutGitRepository = async ( repoUrl: string, + config: Config, logger: Logger, ): Promise => { const parsedGitLocation = parseGitUrl(repoUrl); - const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl); - const token = await getTokenForGitRepo(repoUrl); + const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl, config); + const token = await getTokenForGitRepo(repoUrl, config); + // Initialize a git client + let git = Git.fromAuth({ logger }); + + // Docs about why username and password are set to these specific values. + // https://isomorphic-git.org/docs/en/onAuth#oauth2-tokens + if (token) { + const type = getGitRepoType(repoUrl); + switch (type) { + case 'github': + git = Git.fromAuth({ + username: 'x-access-token', + password: token, + logger, + }); + parsedGitLocation.token = `x-access-token:${token}`; + break; + case 'gitlab': + git = Git.fromAuth({ + username: 'oauth2', + password: token, + logger, + }); + parsedGitLocation.token = `dummyUsername:${token}`; + parsedGitLocation.git_suffix = true; + break; + case 'azure/api': + git = Git.fromAuth({ + username: 'notempty', + password: token, + logger: logger, + }); + break; + default: + parsedGitLocation.token = `:${token}`; + } + } + + // Pull from repository if it has already been cloned. if (fs.existsSync(repositoryTmpPath)) { try { - const repository = await Repository.open(repositoryTmpPath); - const currentBranchName = ( - await repository.getCurrentBranch() - ).shorthand(); - await repository.fetch('origin'); - await repository.mergeBranches( - currentBranchName, - `origin/${currentBranchName}`, - ); + const currentBranchName = await git.currentBranch({ + dir: repositoryTmpPath, + }); + + await git.fetch({ dir: repositoryTmpPath, remote: 'origin' }); + await git.merge({ + dir: repositoryTmpPath, + theirs: `origin/${currentBranchName}`, + ours: currentBranchName || undefined, + author: { name: 'Backstage TechDocs', email: 'techdocs@backstage.io' }, + committer: { + name: 'Backstage TechDocs', + email: 'techdocs@backstage.io', + }, + }); return repositoryTmpPath; } catch (e) { logger.info( @@ -144,52 +187,44 @@ export const checkoutGitRepository = async ( } } - if (token) { - const type = getGitRepoType(repoUrl); - switch (type) { - case 'gitlab': - // Personal Access Token - parsedGitLocation.token = `dummyUsername:${token}`; - parsedGitLocation.git_suffix = true; - break; - case 'github': - parsedGitLocation.token = `${token}:x-oauth-basic`; - break; - default: - parsedGitLocation.token = `:${token}`; - } - } - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); fs.mkdirSync(repositoryTmpPath, { recursive: true }); - await Clone.clone(repositoryCheckoutUrl, repositoryTmpPath); + await git.clone({ url: repositoryCheckoutUrl, dir: repositoryTmpPath }); return repositoryTmpPath; }; export const getLastCommitTimestamp = async ( - repositoryUrl: string, + repositoryLocation: string, logger: Logger, ): Promise => { - const repositoryLocation = await checkoutGitRepository(repositoryUrl, logger); + const git = Git.fromAuth({ logger }); + const sha = await git.resolveRef({ dir: repositoryLocation, ref: 'HEAD' }); + const commit = await git.readCommit({ dir: repositoryLocation, sha }); - const repository = await Repository.open(repositoryLocation); - const commit = await repository.getReferenceCommit('HEAD'); - - return commit.date().getTime(); + return commit.commit.committer.timestamp; }; export const getDocFilesFromRepository = async ( reader: UrlReader, entity: Entity, -): Promise => { + opts?: { etag?: string; logger?: Logger }, +): Promise => { const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - const response = await reader.readTree(target); + opts?.logger?.info(`Reading files from ${target}`); + // readTree will throw NotModifiedError if etag has not changed. + const readTreeResponse = await reader.readTree(target, { etag: opts?.etag }); + const preparedDir = await readTreeResponse.dir(); - return await response.dir(); + opts?.logger?.info(`Tree downloaded and stored at ${preparedDir}`); + + return { + preparedDir, + etag: readTreeResponse.etag, + }; }; diff --git a/packages/techdocs-common/src/index.ts b/packages/techdocs-common/src/index.ts new file mode 100644 index 0000000000..a6e1831049 --- /dev/null +++ b/packages/techdocs-common/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './stages'; +export * from './helpers'; +export * from './default-branch'; +export * from './git-auth'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs.yml b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs.yml similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs.yml rename to packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs.yml diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs_with_repo_url.yml b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_with_repo_url.yml similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs_with_repo_url.yml rename to packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_with_repo_url.yml diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts b/packages/techdocs-common/src/stages/generate/generators.test.ts similarity index 89% rename from plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts rename to packages/techdocs-common/src/stages/generate/generators.test.ts index a9303c3794..3b59d6d007 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts +++ b/packages/techdocs-common/src/stages/generate/generators.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Generators, TechdocsGenerator } from './'; +import { Generators, TechdocsGenerator } from '.'; import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; @@ -39,10 +39,7 @@ describe('generators', () => { it('should return correct registered generator', async () => { const generators = new Generators(); - const techdocs = new TechdocsGenerator( - logger, - ConfigReader.fromConfigs([]), - ); + const techdocs = new TechdocsGenerator(logger, new ConfigReader({})); generators.register('techdocs', techdocs); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts b/packages/techdocs-common/src/stages/generate/generators.ts similarity index 75% rename from plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts rename to packages/techdocs-common/src/stages/generate/generators.ts index f28a169942..cbfa5e7553 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts +++ b/packages/techdocs-common/src/stages/generate/generators.ts @@ -14,18 +14,32 @@ * limitations under the License. */ +import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { TechdocsGenerator } from '.'; import { GeneratorBase, SupportedGeneratorKey, GeneratorBuilder, } from './types'; - -import { Entity } from '@backstage/catalog-model'; import { getGeneratorKey } from './helpers'; export class Generators implements GeneratorBuilder { private generatorMap = new Map(); + static async fromConfig( + config: Config, + { logger }: { logger: Logger }, + ): Promise { + const generators = new Generators(); + + const techdocsGenerator = new TechdocsGenerator(logger, config); + generators.register('techdocs', techdocsGenerator); + + return generators; + } + register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase) { this.generatorMap.set(generatorKey, generator); } diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts similarity index 71% rename from plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts rename to packages/techdocs-common/src/stages/generate/helpers.test.ts index 54ef0b1e83..fbca6d1ed2 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fs from 'fs-extra'; -import os from 'os'; -import { resolve as resolvePath } from 'path'; -import Stream, { PassThrough } from 'stream'; +import { getVoidLogger } from '@backstage/backend-common'; import Docker from 'dockerode'; +import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import * as winston from 'winston'; -import { - runDockerContainer, - getGeneratorKey, - isValidRepoUrlForMkdocs, - getRepoUrlFromLocationAnnotation, - patchMkdocsYmlPreBuild, -} from './helpers'; +import os from 'os'; +import path, { resolve as resolvePath } from 'path'; +import Stream, { PassThrough } from 'stream'; +import { ParsedLocationAnnotation } from '../../helpers'; import { RemoteProtocol } from '../prepare/types'; -import { ParsedLocationAnnotation } from '../../../helpers'; +import { + addBuildTimestampMetadata, + getGeneratorKey, + getRepoUrlFromLocationAnnotation, + isValidRepoUrlForMkdocs, + patchMkdocsYmlPreBuild, + runDockerContainer, + storeEtagMetadata, + UserOptions, +} from './helpers'; const mockEntity = { apiVersion: 'version', @@ -46,7 +49,8 @@ const mkdocsYml = fs.readFileSync( const mkdocsYmlWithRepoUrl = fs.readFileSync( resolvePath(__filename, '../__fixtures__/mkdocs_with_repo_url.yml'), ); -const mockLogger = winston.createLogger(); +const mockLogger = getVoidLogger(); +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; describe('helpers', () => { describe('getGeneratorKey', () => { @@ -80,14 +84,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 +107,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -111,16 +115,16 @@ describe('helpers', () => { imageName, args, expect.any(Stream), - { + expect.objectContaining({ Volumes: { '/content': {}, '/result': {}, }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, - }, + }), ); }); @@ -129,13 +133,37 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); expect(mockDocker.ping).toHaveBeenCalled(); }); + it('should pass through the user and group id from the host machine and set the home dir', async () => { + await runDockerContainer({ + imageName, + args, + docsDir, + outputDir, + dockerClient: mockDocker, + }); + + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + + expect(mockDocker.run).toHaveBeenCalledWith( + imageName, + args, + expect.any(Stream), + expect.objectContaining({ + ...userOptions, + }), + ); + }); + describe('where docker is unavailable', () => { const dockerError = 'a docker error'; @@ -151,7 +179,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); @@ -303,7 +331,7 @@ describe('helpers', () => { const updatedMkdocsYml = await fs.readFile('/mkdocs.yml'); expect(updatedMkdocsYml.toString()).toContain( - "repo_url: 'https://github.com/backstage/backstage'", + 'repo_url: https://github.com/backstage/backstage', ); }); @@ -322,11 +350,87 @@ describe('helpers', () => { const updatedMkdocsYml = await fs.readFile('/mkdocs_with_repo_url.yml'); expect(updatedMkdocsYml.toString()).toContain( - "repo_url: 'https://github.com/backstage/backstage'", + 'repo_url: https://github.com/backstage/backstage', ); expect(updatedMkdocsYml.toString()).not.toContain( - "repo_url: 'https://github.com/neworg/newrepo'", + 'repo_url: https://github.com/neworg/newrepo', ); }); }); + + describe('addBuildTimestampMetadata', () => { + beforeEach(() => { + mockFs.restore(); + mockFs({ + [rootDir]: { + 'invalid_techdocs_metadata.json': 'dsds', + 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should create the file if it does not exist', async () => { + const filePath = path.join(rootDir, 'wrong_techdocs_metadata.json'); + await addBuildTimestampMetadata(filePath, mockLogger); + + // Check if the file exists + await expect( + fs.access(filePath, fs.constants.F_OK), + ).resolves.not.toThrowError(); + }); + + it('should throw error when the JSON is invalid', async () => { + const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json'); + + await expect( + addBuildTimestampMetadata(filePath, mockLogger), + ).rejects.toThrowError('Unexpected token d in JSON at position 0'); + }); + + it('should add build timestamp to the metadata json', async () => { + const filePath = path.join(rootDir, 'techdocs_metadata.json'); + + await addBuildTimestampMetadata(filePath, mockLogger); + + const json = await fs.readJson(filePath); + expect(json.build_timestamp).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe('storeEtagMetadata', () => { + beforeEach(() => { + mockFs.restore(); + mockFs({ + [rootDir]: { + 'invalid_techdocs_metadata.json': 'dsds', + 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should throw error when the JSON is invalid', async () => { + const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json'); + + await expect( + storeEtagMetadata(filePath, 'etag123abc'), + ).rejects.toThrowError('Unexpected token d in JSON at position 0'); + }); + + it('should add etag to the metadata json', async () => { + const filePath = path.join(rootDir, 'techdocs_metadata.json'); + + await storeEtagMetadata(filePath, 'etag123abc'); + + const json = await fs.readJson(filePath); + expect(json.etag).toBe('etag123abc'); + }); + }); }); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts similarity index 75% rename from plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts rename to packages/techdocs-common/src/stages/generate/helpers.ts index 0f60712f5e..a88ed13231 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -14,16 +14,16 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { spawn } from 'child_process'; -import { Writable, PassThrough } from 'stream'; -import Docker from 'dockerode'; -import yaml from 'js-yaml'; -import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; -import { SupportedGeneratorKey } from './types'; -import { ParsedLocationAnnotation } from '../../../helpers'; +import { spawn } from 'child_process'; +import Docker from 'dockerode'; +import fs from 'fs-extra'; +import yaml from 'js-yaml'; +import { PassThrough, Writable } from 'stream'; +import { Logger } from 'winston'; +import { ParsedLocationAnnotation } from '../../helpers'; import { RemoteProtocol } from '../prepare/types'; +import { SupportedGeneratorKey } from './types'; // TODO: Implement proper support for more generators. export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { @@ -39,7 +39,7 @@ type RunDockerContainerOptions = { args: string[]; logStream?: Writable; docsDir: string; - resultDir: string; + outputDir: string; dockerClient: Docker; createOptions?: Docker.ContainerCreateOptions; }; @@ -51,12 +51,18 @@ export type RunCommandOptions = { logStream?: Writable; }; +export type UserOptions = { + User?: string; +}; + +// To be replaced by a runDockerContainer from backend-common +// shared between Scaffolder and TechDocs and any other plugin. export async function runDockerContainer({ imageName, args, logStream = new PassThrough(), docsDir, - resultDir, + outputDir, dockerClient, createOptions, }: RunDockerContainerOptions) { @@ -78,6 +84,17 @@ export async function runDockerContainer({ }); }); + const userOptions: UserOptions = {}; + // @ts-ignore + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, @@ -89,8 +106,9 @@ export async function runDockerContainer({ }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, + ...userOptions, ...createOptions, }, ); @@ -209,7 +227,7 @@ export const getRepoUrlFromLocationAnnotation = ( }; /** - * Update the mkdocs.yml file before TechDocs generator uses it to build docs site. + * Update the mkdocs.yml file before TechDocs generator uses it to generate docs site. * * List of tasks: * - Add repo_url if it does not exists @@ -241,7 +259,7 @@ export const patchMkdocsYmlPreBuild = async ( let mkdocsYml: any; try { - mkdocsYml = yaml.safeLoad(mkdocsYmlFileString); + mkdocsYml = yaml.load(mkdocsYmlFileString); // mkdocsYml should be an object type after successful parsing. // But based on its type definition, it can also be a string or undefined, which we don't want. @@ -267,7 +285,7 @@ export const patchMkdocsYmlPreBuild = async ( } try { - await fs.writeFile(mkdocsYmlPath, yaml.safeDump(mkdocsYml), 'utf8'); + await fs.writeFile(mkdocsYmlPath, yaml.dump(mkdocsYml), 'utf8'); } catch (error) { logger.warn( `Could not write to ${mkdocsYmlPath} after updating it before running the generator. ${error.message}`, @@ -275,3 +293,51 @@ export const patchMkdocsYmlPreBuild = async ( return; } }; + +/** + * Update the techdocs_metadata.json to add a new build timestamp metadata. Create the .json file if it doesn't exist. + * + * @param {string} techdocsMetadataPath File path to techdocs_metadata.json + */ +export const addBuildTimestampMetadata = async ( + techdocsMetadataPath: string, + logger: Logger, +): Promise => { + // check if file exists, create if it does not. + try { + await fs.access(techdocsMetadataPath, fs.constants.F_OK); + } catch (err) { + // Bootstrap file with empty JSON + await fs.writeJson(techdocsMetadataPath, JSON.parse('{}')); + } + // check if valid Json + let json; + try { + json = await fs.readJson(techdocsMetadataPath); + } catch (err) { + const message = `Invalid JSON at ${techdocsMetadataPath} with error ${err.message}`; + logger.error(message); + throw new Error(message); + } + + json.build_timestamp = Date.now(); + await fs.writeJson(techdocsMetadataPath, json); + return; +}; + +/** + * Update the techdocs_metadata.json to add etag of the prepared tree (e.g. commit SHA or actual Etag of the resource). + * This is helpful to check if a TechDocs site in storage has gone outdated, without maintaining an in-memory build info + * per Backstage instance. + * + * @param {string} techdocsMetadataPath File path to techdocs_metadata.json + * @param {string} etag + */ +export const storeEtagMetadata = async ( + techdocsMetadataPath: string, + etag: string, +): Promise => { + const json = await fs.readJson(techdocsMetadataPath); + json.etag = etag; + await fs.writeJson(techdocsMetadataPath, json); +}; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts b/packages/techdocs-common/src/stages/generate/index.ts similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/index.ts rename to packages/techdocs-common/src/stages/generate/index.ts diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts similarity index 68% rename from plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts rename to packages/techdocs-common/src/stages/generate/techdocs.ts index faefd8ac16..133755ecd2 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -14,23 +14,18 @@ * 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 path from 'path'; +import { PassThrough } from 'stream'; +import { Logger } from 'winston'; import { - GeneratorBase, - GeneratorRunOptions, - GeneratorRunResult, -} from './types'; -import { - runDockerContainer, - runCommand, + addBuildTimestampMetadata, patchMkdocsYmlPreBuild, + runCommand, + runDockerContainer, + storeEtagMetadata, } from './helpers'; +import { GeneratorBase, GeneratorRunOptions } from './types'; type TechdocsGeneratorOptions = { // This option enables users to configure if they want to use TechDocs container @@ -64,40 +59,38 @@ 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-'), - ); + etag, + }: 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 +98,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 +113,32 @@ 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 }; + /** + * Post Generate steps + */ + + // Add build timestamp to techdocs_metadata.json + // Creates techdocs_metadata.json if file does not exist. + await addBuildTimestampMetadata( + path.join(outputDir, 'techdocs_metadata.json'), + this.logger, + ); + + // Add etag of the prepared tree to techdocs_metadata.json + // Assumes that the file already exists. + if (etag) { + await storeEtagMetadata( + path.join(outputDir, 'techdocs_metadata.json'), + etag, + ); + } } } diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts similarity index 70% rename from plugins/techdocs-backend/src/techdocs/stages/generate/types.ts rename to packages/techdocs-common/src/stages/generate/types.ts index 6d9ce5afca..fda41ed442 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -13,37 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Writable } from 'stream'; -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; -}; +import Docker from 'dockerode'; +import { Writable } from 'stream'; +import { ParsedLocationAnnotation } from '../../helpers'; /** * 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 {string} etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json. * @param {Writable} [logStream] A dedicated log stream */ export type GeneratorRunOptions = { - directory: string; + inputDir: string; + outputDir: string; dockerClient: Docker; - parsedLocationAnnotation: ParsedLocationAnnotation; + parsedLocationAnnotation?: ParsedLocationAnnotation; + etag?: string; 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/plugins/techdocs-backend/src/techdocs/stages/index.ts b/packages/techdocs-common/src/stages/index.ts similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/index.ts rename to packages/techdocs-common/src/stages/index.ts diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts similarity index 75% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts rename to packages/techdocs-common/src/stages/prepare/commonGit.test.ts index 843df476e9..dcc06313bd 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts @@ -15,8 +15,9 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { checkoutGitRepository } from '../../helpers'; import { CommonGitPreparer } from './commonGit'; -import { checkoutGitRepository } from '../../../helpers'; function normalizePath(path: string) { return path @@ -25,9 +26,10 @@ function normalizePath(path: string) { .join('/'); } -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), +jest.mock('../../helpers', () => ({ + ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), + getLastCommitTimestamp: jest.fn(() => 12345678), })); const createMockEntity = (annotations = {}) => { @@ -43,50 +45,52 @@ const createMockEntity = (annotations = {}) => { }; }; +const mockConfig = new ConfigReader({}); + const logger = getVoidLogger(); describe('commonGit preparer', () => { it('should prepare temp docs path from github repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': 'github:https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(1); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', ); }); it('should prepare temp docs path from gitlab repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': 'gitlab:https://gitlab.com/xesjkeee/go-logger/blob/master/catalog-info.yaml', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(2); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/catalog-info.yaml', ); }); it('should prepare temp docs path from azure repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(3); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/template.yaml', ); }); diff --git a/packages/techdocs-common/src/stages/prepare/commonGit.ts b/packages/techdocs-common/src/stages/prepare/commonGit.ts new file mode 100644 index 0000000000..46b96675f3 --- /dev/null +++ b/packages/techdocs-common/src/stages/prepare/commonGit.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NotModifiedError } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import parseGitUrl from 'git-url-parse'; +import path from 'path'; +import { Logger } from 'winston'; +import { + checkoutGitRepository, + getLastCommitTimestamp, + parseReferenceAnnotation, +} from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; + +export class CommonGitPreparer implements PreparerBase { + private readonly config: Config; + private readonly logger: Logger; + + constructor(config: Config, logger: Logger) { + this.config = config; + this.logger = logger; + } + + async prepare( + entity: Entity, + options?: { etag?: string }, + ): Promise { + this.logger.warn( + 'You are using the legacy git preparer in TechDocs which will be removed in near future (March 2021). ' + + 'Migrate to URL reader by updating `backstage.io/techdocs-ref` annotation in `catalog-info.yaml` ' + + 'to be prefixed with `url:`. Read the migration guide and benefits at https://github.com/backstage/backstage/issues/4409 ', + ); + + const { target } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + + try { + // Update repository or do a fresh clone. + const repoPath = await checkoutGitRepository( + target, + this.config, + this.logger, + ); + // Check if etag has changed for cache invalidation. + const etag = await getLastCommitTimestamp(repoPath, this.logger); + if (options?.etag === etag.toString()) { + throw new NotModifiedError(); + } + + const parsedGitLocation = parseGitUrl(target); + return { + preparedDir: path.join(repoPath, parsedGitLocation.filepath), + etag: etag.toString(), + }; + } catch (error) { + if (error instanceof NotModifiedError) { + this.logger.debug(`Cache is valid for etag ${options?.etag}`); + } else { + this.logger.debug(`Repo checkout failed with error ${error.message}`); + } + throw error; + } + } +} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts similarity index 65% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts rename to packages/techdocs-common/src/stages/prepare/dir.test.ts index dc2b1d7d48..67fa0dd21f 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { checkoutGitRepository } from '../../helpers'; import { DirectoryPreparer } from './dir'; -import { getVoidLogger } from '@backstage/backend-common'; -import { checkoutGitRepository } from '../../../helpers'; function normalizePath(path: string) { return path @@ -24,9 +25,10 @@ function normalizePath(path: string) { .join('/'); } -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), +jest.mock('../../helpers', () => ({ + ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch/'), + getLastCommitTimestamp: jest.fn(() => 12345678), })); const logger = getVoidLogger(); @@ -44,9 +46,20 @@ 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(logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -54,13 +67,16 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:./our-documentation', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( - '/directory/our-documentation', - ); + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual('/directory/our-documentation'); }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -68,13 +84,16 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( - '/our-documentation/techdocs', - ); + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual('/our-documentation/techdocs'); }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -82,7 +101,8 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:./docs', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/docs', ); expect(checkoutGitRepository).toHaveBeenCalledTimes(1); diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts new file mode 100644 index 0000000000..08832938df --- /dev/null +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + InputError, + NotModifiedError, + UrlReader, +} from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import parseGitUrl from 'git-url-parse'; +import path from 'path'; +import { Logger } from 'winston'; +import { + checkoutGitRepository, + getLastCommitTimestamp, + parseReferenceAnnotation, +} from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; + +export class DirectoryPreparer implements PreparerBase { + 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, + options?: { etag?: string }, + ): Promise { + const { type, target } = parseReferenceAnnotation( + 'backstage.io/managed-by-location', + entity, + ); + + this.logger.debug( + `Building docs for entity with type 'dir' and managed-by-location '${type}'`, + ); + switch (type) { + case 'url': { + const response = await this.reader.readTree(target, { + etag: options?.etag, + }); + const preparedDir = await response.dir(); + return { + preparedDir, + etag: response.etag, + }; + } + case 'github': + case 'gitlab': + case 'azure/api': { + const parsedGitLocation = parseGitUrl(target); + const repoLocation = await checkoutGitRepository( + target, + this.config, + this.logger, + ); + + // Check if etag has changed for cache invalidation. + const etag = await getLastCommitTimestamp(repoLocation, this.logger); + if (options?.etag === etag.toString()) { + throw new NotModifiedError(); + } + return { + preparedDir: path.dirname( + path.join(repoLocation, parsedGitLocation.filepath), + ), + etag: etag.toString(), + }; + } + case 'file': + return { + preparedDir: path.dirname(target), + // Instead of supporting caching on local sources, use techdocs-cli for local development and debugging. + etag: '', + }; + default: + throw new InputError(`Unable to resolve location type ${type}`); + } + } + + async prepare(entity: Entity): Promise { + this.logger.warn( + 'You are using the legacy dir preparer in TechDocs which will be removed in near future (March 2021). ' + + 'Migrate to URL reader by updating `backstage.io/techdocs-ref` annotation in `catalog-info.yaml` ' + + 'to be prefixed with `url:`. Read the migration guide and benefits at https://github.com/backstage/backstage/issues/4409 ', + ); + + const { target } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + + // This will throw NotModified error if etag has not changed. + const response = await this.resolveManagedByLocationToDir(entity); + + return { + preparedDir: path.resolve(response.preparedDir, target), + etag: response.etag, + }; + } +} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/packages/techdocs-common/src/stages/prepare/index.ts similarity index 90% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts rename to packages/techdocs-common/src/stages/prepare/index.ts index 6c73725a3a..dcb178bafa 100644 --- a/plugins/techdocs-backend/src/techdocs/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/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts similarity index 51% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts rename to packages/techdocs-common/src/stages/prepare/preparers.ts index 2f0df47de4..de825d5d41 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -13,14 +13,47 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; +import { Logger } from 'winston'; +import { UrlReader } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { parseReferenceAnnotation } from '../../../helpers'; +import { Config } from '@backstage/config'; +import { DirectoryPreparer, CommonGitPreparer, UrlPreparer } from '.'; +import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; +import { parseReferenceAnnotation } from '../../helpers'; + +type factoryOptions = { + logger: Logger; + reader: UrlReader; +}; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); + static async fromConfig( + config: Config, + { logger, reader }: factoryOptions, + ): Promise { + const preparers = new Preparers(); + + 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); + + return preparers; + } + register(protocol: RemoteProtocol, preparer: PreparerBase) { this.preparerMap.set(protocol, preparer); } diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts b/packages/techdocs-common/src/stages/prepare/types.ts similarity index 60% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts rename to packages/techdocs-common/src/stages/prepare/types.ts index 97e75306c3..96510e8eec 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts +++ b/packages/techdocs-common/src/stages/prepare/types.ts @@ -16,13 +16,31 @@ import type { Entity } from '@backstage/catalog-model'; import { Logger } from 'winston'; +export type PreparerResponse = { + /** + * The path to directory where the tree is downloaded. + */ + preparedDir: string; + /** + * A unique identifer of the tree blob, usually the commit SHA or etag from the target. + */ + etag: string; +}; + export type PreparerBase = { /** * Given an Entity definition from the Service Catalog, go and prepare a directory - * with contents from the location in temporary storage and return the path + * with contents from the location in temporary storage and return the path. + * * @param entity The entity from the Service Catalog + * @param options.etag (Optional) If etag is provider, it will be used to check if the target has + * updated since the last build. + * @throws {NotModifiedError} when the prepared directory has not been changed since the last build. */ - prepare(entity: Entity, opts?: { logger: Logger }): Promise; + prepare( + entity: Entity, + options?: { logger?: Logger; etag?: string }, + ): Promise; }; export type PreparerBuilder = { @@ -30,10 +48,14 @@ export type PreparerBuilder = { get(entity: Entity): PreparerBase; }; +/** + * Everything except `url` will be deprecated. + * Read more https://github.com/backstage/backstage/issues/4409 + */ export type RemoteProtocol = + | 'url' | 'dir' | 'github' | 'gitlab' | 'file' - | 'azure/api' - | 'url'; + | 'azure/api'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts b/packages/techdocs-common/src/stages/prepare/url.ts similarity index 56% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts rename to packages/techdocs-common/src/stages/prepare/url.ts index 330db05aa4..9e4715a4ee 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts +++ b/packages/techdocs-common/src/stages/prepare/url.ts @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { NotModifiedError, UrlReader } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { PreparerBase } from './types'; -import { getDocFilesFromRepository } from '../../../helpers'; - import { Logger } from 'winston'; -import { UrlReader } from '@backstage/backend-common'; +import { getDocFilesFromRepository } from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; export class UrlPreparer implements PreparerBase { private readonly logger: Logger; @@ -29,13 +28,25 @@ export class UrlPreparer implements PreparerBase { this.reader = reader; } - async prepare(entity: Entity): Promise { + async prepare( + entity: Entity, + options?: { etag?: string }, + ): Promise { try { - return getDocFilesFromRepository(this.reader, entity); + return await getDocFilesFromRepository(this.reader, entity, { + etag: options?.etag, + logger: this.logger, + }); } catch (error) { - this.logger.debug( - `Unable to fetch files for building docs ${error.message}`, - ); + // NotModifiedError means that etag based cache is still valid. + if (error instanceof NotModifiedError) { + this.logger.debug(`Cache is valid for etag ${options?.etag}`); + } else { + this.logger.debug( + `Unable to fetch files for building docs ${error.message}`, + ); + } + throw error; } } diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts new file mode 100644 index 0000000000..218d3dd88f --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -0,0 +1,249 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, +} from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import os from 'os'; +import path from 'path'; +import * as winston from 'winston'; +import { AwsS3Publish } from './awsS3'; +import { PublisherBase, TechDocsMetadata } from './types'; + +// NOTE: /packages/techdocs-common/__mocks__ is being used to mock aws-sdk client library + +const createMockEntity = (annotations = {}): Entity => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const createMockEntityName = (): EntityName => ({ + kind: 'TestKind', + name: 'test-component-name', + namespace: 'test-namespace', +}); + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); +}; + +const logger = winston.createLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(() => { + mockFs.restore(); + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + bucketName: 'bucketName', + }, + }, + }, + }); + + publisher = AwsS3Publish.fromConfig(mockConfig, logger); +}); + +describe('AwsS3Publish', () => { + describe('publish', () => { + beforeEach(() => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + attachments: { + 'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]), + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + }); + + it('should fail to publish a directory', async () => { + expect.assertions(3); + const wrongPathToGeneratedDirectory = path.join( + rootDir, + 'wrong', + 'path', + 'to', + 'generatedDirectory', + ); + + const entity = createMockEntity(); + await expect( + publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }), + ).rejects.toThrowError(); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => { + expect(error.message).toEqual( + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + expect.stringContaining( + `Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + ); + expect(error.message).toEqual( + expect.stringContaining(wrongPathToGeneratedDirectory), + ); + }); + 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); + }); + }); + + describe('fetchTechDocsMetadata', () => { + it('should return tech docs metadata', 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 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); + + await publisher + .fetchTechDocsMetadata(entityNameMock) + .catch(error => + expect(error).toEqual( + new Error( + `TechDocs metadata fetch failed, The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + ), + ), + ); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts new file mode 100644 index 0000000000..a1abd5899d --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -0,0 +1,283 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Config } from '@backstage/config'; +import aws, { Credentials } from 'aws-sdk'; +import { ManagedUpload } from 'aws-sdk/clients/s3'; +import express from 'express'; +import fs from 'fs-extra'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; +import path from 'path'; +import { Readable } from 'stream'; +import { Logger } from 'winston'; +import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; + +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 bucketName = ''; + try { + bucketName = config.getString('techdocs.publisher.awsS3.bucketName'); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'awsS3' in your app config, " + + 'techdocs.publisher.awsS3.bucketName is required.', + ); + } + + // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. + // 1. AWS environment variables + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html + // 2. AWS shared credentials file at ~/.aws/credentials + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html + // 3. IAM Roles for EC2 + // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-iam.html + const credentialsConfig = config.getOptionalConfig( + 'techdocs.publisher.awsS3.credentials', + ); + let accessKeyId = undefined; + let secretAccessKey = undefined; + let credentials: Credentials | CredentialsOptions | undefined = undefined; + if (credentialsConfig) { + const roleArn = credentialsConfig.getOptionalString('roleArn'); + if (roleArn && aws.config.credentials instanceof Credentials) { + credentials = new aws.ChainableTemporaryCredentials({ + masterCredentials: aws.config.credentials as Credentials, + params: { + RoleSessionName: 'backstage-aws-techdocs-s3-publisher', + RoleArn: roleArn, + }, + }); + } else { + accessKeyId = credentialsConfig.getOptionalString('accessKeyId'); + secretAccessKey = credentialsConfig.getOptionalString( + 'secretAccessKey', + ); + if (accessKeyId && secretAccessKey) { + credentials = { + accessKeyId, + 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. + const region = config.getOptionalString('techdocs.publisher.awsS3.region'); + + const storageClient = new aws.S3({ + credentials, + ...(region && { + region, + }), + }); + + // Check if the defined bucket exists. Being able to connect means the configuration is good + // and the storage client will work. + storageClient.headBucket( + { + Bucket: bucketName, + }, + err => { + if (err) { + logger.error( + `Could not retrieve metadata about the AWS S3 bucket ${bucketName}. ` + + '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', + ); + logger.error(`from AWS client library: ${err.message}`); + throw new Error(); + } else { + logger.info( + `Successfully connected to the AWS S3 bucket ${bucketName}.`, + ); + } + }, + ); + + return new AwsS3Publish(storageClient, bucketName, logger); + } + + constructor( + private readonly storageClient: aws.S3, + private readonly bucketName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.bucketName = bucketName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the S3 bucket. + * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: S3 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 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 + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = path.relative(directory, filePath); + + // Convert destination file path to a POSIX path for uploading. + // S3 expects / as path separator and relativeFilePath will contain \\ on Windows. + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html + const relativeFilePathPosix = relativeFilePath + .split(path.sep) + .join(path.posix.sep); + + // The / delimiter is intentional since it represents the cloud storage and not the local file system. + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = `${entityRootDir}/${relativeFilePathPosix}`; // S3 Bucket file relative path + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => { + const fileStream = fs.createReadStream(filePath); + + const params = { + Bucket: this.bucketName, + Key: destination, + Body: fileStream, + }; + + return this.storageClient.upload(params).promise(); + }); + uploadPromises.push(uploadFile); + } + await Promise.all(uploadPromises); + 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 AWS S3. ${e}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + try { + return await new Promise(async (resolve, reject) => { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + + const stream = this.storageClient + .getObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/techdocs_metadata.json`, + }) + .createReadStream(); + + try { + const techdocsMetadataJson = await streamToBuffer(stream); + 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)); + } + }); + } catch (e) { + throw new Error(`TechDocs metadata fetch failed, ${e.message}`); + } + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return async (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 = path.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + const stream = this.storageClient + .getObject({ Bucket: this.bucketName, Key: filePath }) + .createReadStream(); + try { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + + res.send(await streamToBuffer(stream)); + } catch (err) { + this.logger.warn(err.message); + res.status(404).json(err.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 { + try { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + await this.storageClient + .headObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/index.html`, + }) + .promise(); + 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..cf1ea60592 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -0,0 +1,258 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import os from 'os'; +import path from 'path'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { PublisherBase } from './types'; + +// NOTE: /packages/techdocs-common/__mocks__ is being used to mock Azure client library + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); +}; + +function createLogger() { + const logger = getVoidLogger(); + jest.spyOn(logger, 'info').mockReturnValue(logger); + jest.spyOn(logger, 'error').mockReturnValue(logger); + return logger; +} + +let publisher: PublisherBase; +beforeEach(async () => { + mockFs.restore(); + 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, + createLogger(), + ); +}); + +describe('publishing with valid credentials', () => { + describe('publish', () => { + beforeEach(() => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + }); + + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + expect.assertions(1); + const wrongPathToGeneratedDirectory = path.join( + rootDir, + 'wrong', + 'path', + 'to', + 'generatedDirectory', + ); + + const entity = createMockEntity(); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => { + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ); + + expect(error.message).toEqual( + expect.stringContaining(wrongPathToGeneratedDirectory), + ); + }); + 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); + }); + }); +}); + +describe('error reporting', () => { + it('reports an error when unable to read container properties', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'bad_container', + }, + }, + }, + }); + + const logger = createLogger(); + + let error; + try { + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, + ), + ); + }); + + it('reports an error when bad account credentials', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'failupload', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const logger = createLogger(); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + }, + }); + + let error; + try { + await publisher.publish({ + entity, + directory: entityRootDir, + }); + } catch (e) { + error = e; + } + + expect(error.message).toContain( + `Unable to upload file(s) to Azure Blob Storage.`, + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join( + entityRootDir, + 'index.html', + )} with status code 500`, + ), + ); + + mockFs.restore(); + }); +}); 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..2136ee2bc2 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -0,0 +1,276 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DefaultAzureCredential } from '@azure/identity'; +import { + BlobServiceClient, + StorageSharedKeyCredential, +} from '@azure/storage-blob'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import express from 'express'; +import JSON5 from 'json5'; +import limiterFactory from 'p-limit'; +import { default as path, default as platformPath } from 'path'; +import { Logger } from 'winston'; +import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; + +// 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, + ); + + try { + const response = await storageClient + .getContainerClient(containerName) + .getProperties(); + + if (response._response.status >= 400) { + throw new Error( + `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, + ); + } + } catch (e) { + 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: ${e.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); + + // 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(sourceFilePath => { + // 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 = path.normalize( + path.relative(directory, sourceFilePath), + ); + + // Convert destination file path to a POSIX path for uploading. + // Azure Blob Storage expects / as path separator and relativeFilePath will contain \\ on Windows. + // https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#blob-names + const relativeFilePathPosix = relativeFilePath + .split(path.sep) + .join(path.posix.sep); + + // The / delimiter is intentional since it represents the cloud storage and not the local file system. + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Azure Blob Storage Container file relative path + return limiter(async () => { + const response = await this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(sourceFilePath); + + if (response._response.status >= 400) { + return { + ...response, + error: new Error( + `Upload failed for ${sourceFilePath} with status code ${response._response.status}`, + ), + }; + } + return { + ...response, + error: undefined, + }; + }); + }); + + const responses = await Promise.all(promises); + + const failed = responses.filter(r => r.error); + if (failed.length === 0) { + this.logger.info( + `Successfully uploaded the ${responses.length} generated file(s) for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + } else { + throw new Error( + failed + .map(r => r.error?.message) + .filter(Boolean) + .join(' '), + ); + } + } catch (e) { + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. ${e}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + private download(containerName: string, blobPath: string): Promise { + return new Promise((resolve, reject) => { + const fileStreamChunks: Array = []; + this.storageClient + .getContainerClient(containerName) + .getBlockBlobClient(blobPath) + .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).json(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. + */ + hasDocsBeenGenerated(entity: Entity): Promise { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + return this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists(); + } +} diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts new file mode 100644 index 0000000000..10da0d476a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/googleStorage.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 { getVoidLogger } from '@backstage/backend-common'; +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, +} from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import os from 'os'; +import path from 'path'; +import { GoogleGCSPublish } from './googleStorage'; +import { PublisherBase, TechDocsMetadata } from './types'; + +// NOTE: /packages/techdocs-common/__mocks__ is being used to mock Google Cloud Storage client library + +const createMockEntity = (annotations = {}): Entity => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const createMockEntityName = (): EntityName => ({ + kind: 'TestKind', + name: 'test-component-name', + namespace: 'test-namespace', +}); + +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); +}; + +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + mockFs.restore(); + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'bucketName', + }, + }, + }, + }); + + publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger); +}); + +describe('GoogleGCSPublish', () => { + describe('publish', () => { + beforeEach(() => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + expect.assertions(3); + + const wrongPathToGeneratedDirectory = path.join( + rootDir, + 'wrong', + 'path', + 'to', + 'generatedDirectory', + ); + + const entity = createMockEntity(); + + await expect( + publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }), + ).rejects.toThrowError(); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => { + expect(error.message).toEqual( + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + expect.stringContaining( + `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + ); + expect(error.message).toEqual( + expect.stringContaining(wrongPathToGeneratedDirectory), + ); + }); + + 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); + }); + }); + + describe('fetchTechDocsMetadata', () => { + beforeEach(() => { + mockFs.restore(); + }); + + it('should return tech docs metadata', 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); + }); + + 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); + + await publisher + .fetchTechDocsMetadata(entityNameMock) + .catch(errorMessage => + expect(errorMessage).toEqual( + `The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + ), + ); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts new file mode 100644 index 0000000000..12ecf45b2d --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -0,0 +1,224 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Config } from '@backstage/config'; +import { + FileExistsResponse, + Storage, + UploadResponse, +} from '@google-cloud/storage'; +import express from 'express'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; +import path from 'path'; +import { Logger } from 'winston'; +import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; + +export class GoogleGCSPublish implements PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { + let bucketName = ''; + try { + bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'googleGcs' in your app config, " + + '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 = {}; + 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 && { + credentials: credentialsJson, + }), + }); + + // Check if the defined bucket exists. Being able to connect means the configuration is good + // and the storage client will work. + 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); + } + + constructor( + private readonly storageClient: Storage, + private readonly bucketName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.bucketName = bucketName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the GCS bucket. + * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: GCS 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 limiter = createLimiter(10); + const uploadPromises: Array> = []; + allFilesToUpload.forEach(sourceFilePath => { + // 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 = path.relative(directory, sourceFilePath); + + // Convert destination file path to a POSIX path for uploading. + // GCS expects / as path separator and relativeFilePath will contain \\ on Windows. + // https://cloud.google.com/storage/docs/gsutil/addlhelp/HowSubdirectoriesWork + const relativeFilePathPosix = relativeFilePath + .split(path.sep) + .join(path.posix.sep); + + // The / delimiter is intentional since it represents the cloud storage and not the local file system. + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = `${entityRootDir}/${relativeFilePathPosix}`; // GCS Bucket file relative path + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => + this.storageClient.bucket(this.bucketName).upload(sourceFilePath, { + destination, + }), + ); + uploadPromises.push(uploadFile); + }); + + await Promise.all(uploadPromises); + + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + } catch (e) { + const errorMessage = `Unable to upload file(s) to Google Cloud Storage. ${e}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + fetchTechDocsMetadata(entityName: EntityName): Promise { + return new Promise((resolve, reject) => { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + + const fileStreamChunks: Array = []; + this.storageClient + .bucket(this.bucketName) + .file(`${entityRootDir}/techdocs_metadata.json`) + .createReadStream() + .on('error', err => { + this.logger.error(err.message); + reject(err.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + const techdocsMetadataJson = Buffer.concat(fileStreamChunks).toString( + 'utf-8', + ); + resolve(JSON5.parse(techdocsMetadataJson)); + }); + }); + } + + /** + * 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 = path.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + // 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); + // Send a 404 with a meaningful message if possible. + if (!res.headersSent) { + res.status(404).send(err.message); + } else { + res.destroy(); + } + }) + .pipe(res); + }; + } + + /** + * 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 + .bucket(this.bucketName) + .file(`${entityRootDir}/index.html`) + .exists() + .then((response: FileExistsResponse) => { + resolve(response[0]); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/helpers.test.ts b/packages/techdocs-common/src/stages/publish/helpers.test.ts new file mode 100644 index 0000000000..1fae66f3d2 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/helpers.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import mockFs from 'mock-fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; + +describe('getHeadersForFileExtension', () => { + const correctMapOfExtensions = [ + ['.html', 'text/html; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.svg', 'image/svg+xml'], + ['.json', 'application/json; charset=utf-8'], + ['.this-in-not-an-extension', 'text/plain; charset=utf-8'], + ]; + + test.each(correctMapOfExtensions)( + 'check content-type for %s extension', + (extension, expectedContentType) => { + const headers = getHeadersForFileExtension(extension); + expect(headers).toHaveProperty('Content-Type'); + expect(headers['Content-Type'].toLowerCase()).toBe(expectedContentType); + }, + ); +}); + +describe('getFileTreeRecursively', () => { + const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + + beforeEach(() => { + mockFs({ + [root]: { + file1: '', + subDirA: { + file2: '', + emptyDir1: mockFs.directory(), + }, + emptyDir2: mockFs.directory(), + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('returns complete file tree of a path', async () => { + const fileList = await getFileTreeRecursively(root); + expect(fileList.length).toBe(2); + expect(fileList).toContain(path.resolve(root, 'file1')); + expect(fileList).toContain(path.resolve(root, 'subDirA/file2')); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/helpers.ts b/packages/techdocs-common/src/stages/publish/helpers.ts new file mode 100644 index 0000000000..138ec611e0 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/helpers.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import mime from 'mime-types'; +import recursiveReadDir from 'recursive-readdir'; + +export type responseHeadersType = { + 'Content-Type': string; +}; + +/** + * Some files need special headers to be used correctly by the frontend. This function + * generates headers in the response to those file requests. + * @param {string} fileExtension .html, .css, .js, .png etc. + */ +export const getHeadersForFileExtension = ( + fileExtension: string, +): responseHeadersType => { + return { + 'Content-Type': + mime.contentType(fileExtension) || 'text/plain; charset=utf-8', + } as responseHeadersType; +}; + +/** + * Recursively traverse all the sub-directories of a path and return + * a list of absolute paths of all the files. e.g. tree command in Unix + * + * @example + * + * /User/username/my_dir + * dirA + * | subDirA + * | | file1 + * EmptyDir + * dirB + * | file2 + * file3 + * + * getFileListRecursively('/Users/username/myDir') + * // returns + * [ + * '/User/username/my_dir/dirA/subDirA/file1', + * '/User/username/my_dir/dirB/file2', + * '/User/username/my_dir/file3' + * ] + * @param rootDirPath Absolute path to the root directory. + */ +export const getFileTreeRecursively = async ( + rootDirPath: string, +): Promise => { + // Iterate on all the files in the directory and its sub-directories + const fileList = await recursiveReadDir(rootDirPath).catch(error => { + throw new Error(`Failed to read template directory: ${error.message}`); + }); + return fileList; +}; diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts new file mode 100644 index 0000000000..dbf4ff732a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/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 { Publisher } from './publish'; +export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts similarity index 57% rename from plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts rename to packages/techdocs-common/src/stages/publish/local.test.ts index 18344489e1..cd1a17ab5f 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -13,14 +13,13 @@ * 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'; const createMockEntity = (annotations = {}) => { @@ -38,38 +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 () => { + mockFs({ + [tmpDir]: { + 'index.html': '', + }, + }); + const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/api/techdocs'), getExternalBaseUrl: jest.fn(), }; - const publisher = new LocalPublish(logger, testDiscovery); + const mockConfig = new ConfigReader({}); + const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); - const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); + await publisher.publish({ entity: mockEntity, directory: tmpDir }); - expect(tempDir).toBeTruthy(); + expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); - fs.closeSync(fs.openSync(path.join(tempDir, '/mock-file'), 'w')); - - await publisher.publish({ entity: mockEntity, directory: tempDir }); - const publishDir = path.resolve( - __dirname, - `../../../../static/docs/${mockEntity.metadata.name}`, - ); - - const resultDir = path.resolve( - __dirname, - `../../../../static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, - ); - - expect(fs.existsSync(resultDir)).toBeTruthy(); - expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); - - 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 new file mode 100644 index 0000000000..ac09331dcd --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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 { + PublisherBase, + PublishRequest, + PublishResponse, + TechDocsMetadata, +} from './types'; + +// 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 { + // 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( + // @ts-ignore + private readonly config: Config, + private readonly logger: Logger, + private readonly discovery: PluginEndpointDiscovery, + ) { + this.config = config; + this.logger = logger; + this.discovery = discovery; + } + + publish({ entity, directory }: PublishRequest): Promise { + const entityNamespace = entity.metadata.namespace ?? 'default'; + + const publishDir = path.join( + staticDocsDir, + entityNamespace, + entity.kind, + entity.metadata.name, + ); + + if (!fs.existsSync(publishDir)) { + this.logger.info(`Could not find ${publishDir}, creating the directory.`); + fs.mkdirSync(publishDir, { recursive: true }); + } + + return new Promise((resolve, reject) => { + fs.copy(directory, publishDir, err => { + if (err) { + this.logger.debug( + `Failed to copy docs from ${directory} to ${publishDir}`, + ); + reject(err); + } + this.logger.info(`Published site stored at ${publishDir}`); + this.discovery + .getBaseUrl('techdocs') + .then(techdocsApiUrl => { + resolve({ + remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, + }); + }) + .catch(reason => { + reject(reason); + }); + }); + }); + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const metadataPath = path.join( + staticDocsDir, + entityName.namespace, + entityName.kind, + entityName.name, + 'techdocs_metadata.json', + ); + + 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 { + return express.static(staticDocsDir); + } + + async hasDocsBeenGenerated(entity: Entity): Promise { + const namespace = entity.metadata.namespace ?? 'default'; + + 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 new file mode 100644 index 0000000000..d1faf82b78 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +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 = { + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getExternalBaseUrl: jest.fn(), +}; + +describe('Publisher', () => { + beforeEach(() => { + jest.resetModules(); // clear the cache + }); + + it('should create local publisher by default', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(LocalPublish); + }); + + it('should create local publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'local', + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(LocalPublish); + }); + + it('should create google gcs publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'bucketName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(GoogleGCSPublish); + }); + + it('should create AWS S3 publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + bucketName: 'bucketName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + 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 new file mode 100644 index 0000000000..a4e33a2d9c --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/publish.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 { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +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; + discovery: PluginEndpointDiscovery; +}; + +/** + * Factory class to create a TechDocs publisher based on defined publisher type in app config. + * Uses `techdocs.publisher.type`. + */ +export class Publisher { + static async fromConfig( + config: Config, + { logger, discovery }: factoryOptions, + ): Promise { + const publisherType = (config.getOptionalString( + 'techdocs.publisher.type', + ) ?? 'local') as PublisherType; + + switch (publisherType) { + case 'googleGcs': + logger.info('Creating Google Storage Bucket publisher for TechDocs'); + 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); + default: + 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 new file mode 100644 index 0000000000..5e953deb81 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 express from 'express'; + +/** + * Key for all the different types of TechDocs publishers that are supported. + */ +export type PublisherType = + | 'local' + | 'googleGcs' + | 'awsS3' + | 'azureBlobStorage'; + +export type PublishRequest = { + entity: Entity; + /* The Path to the directory where the generated files are stored. */ + directory: string; +}; + +/* `remoteUrl` is the URL which serves files from the local publisher's static directory. */ +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. + * It also provides APIs to communicate with the storage service. + */ +export interface PublisherBase { + /** + * Store the generated static files onto a storage service (either local filesystem or external service). + * + * @param request Object containing the entity from the service + * catalog, and the directory that contains the generated static files from TechDocs. + */ + publish(request: PublishRequest): Promise; + + /** + * 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; + + /** + * Route middleware to serve static documentation files for an entity. + */ + docsRouter(): express.Handler; + + /** + * Check if the index.html is present for the Entity at the Storage location. + */ + hasDocsBeenGenerated(entityName: Entity): Promise; +} diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index b35aef77d3..ab1fea89b5 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@types/react": "^16.9", "react": "^16.12.0", "react-dom": "^16.12.0" diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index dbeb79ed78..50f656ec67 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/test-utils +## 0.1.8 + +### Patch Changes + +- dc12852c9: Allow `ExternalRouteRef` instances to be passed as a route ref to `mountedRoutes`. +- Updated dependencies [3a58084b6] +- Updated dependencies [1407b34c6] +- Updated dependencies [b6c4f485d] +- Updated dependencies [3a58084b6] + - @backstage/core-api@0.2.11 + +## 0.1.7 + +### Patch Changes + +- b51ee6ece: Added `mountedRoutes` option to `wrapInTestApp`, allowing routes to be associated to concrete paths to make `useRouteRef` usable in tested components. + +## 0.1.6 + +### Patch Changes + +- 1dc445e89: Update to use new plugin extension API +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + +## 0.1.5 + +### Patch Changes + +- e1f4e24ef: Fix @backstage/cli not being a devDependency +- Updated dependencies [b6557c098] +- Updated dependencies [d8d5a17da] +- Updated dependencies [1665ae8bb] + - @backstage/core-api@0.2.5 + - @backstage/theme@0.2.2 + ## 0.1.4 ### Patch Changes diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md index 38bf1e83cf..0f95d4ab26 100644 --- a/packages/test-utils/README.md +++ b/packages/test-utils/README.md @@ -4,7 +4,7 @@ This package provides utilities that can be used to test plugins and apps for Ba ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save-dev @backstage/test-utils diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 86997a6767..47d223539e 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.4", + "version": "0.1.8", "private": false, "publishConfig": { "access": "public", @@ -29,13 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/core-api": "^0.2.4", + "@backstage/core-api": "^0.2.11", "@backstage/test-utils-core": "^0.1.1", - "@backstage/theme": "^0.2.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/react": "^16.9", "msw": "^0.21.3", @@ -46,6 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/cli": "^0.6.2", "@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 464c547ecc..4899f07061 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -14,16 +14,18 @@ * limitations under the License. */ -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp, renderInTestApp } from './appWrappers'; 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'; @@ -54,7 +56,7 @@ describe('wrapInTestApp', () => { it('should render a component in a test app without warning about missing act()', async () => { const { error } = await withLogCollector(['error'], async () => { - const Foo: FC<{}> = () => { + const Foo = () => { return

foo

; }; @@ -66,7 +68,7 @@ describe('wrapInTestApp', () => { }); it('should render a node in a test app', async () => { - const Foo: FC<{}> = () => { + const Foo = () => { return

foo

; }; @@ -75,7 +77,7 @@ describe('wrapInTestApp', () => { }); it('should provide mock API implementations', async () => { - const A: FC<{}> = () => { + const A = () => { const errorApi = useApi(errorApiRef); errorApi.post(new Error('NOPE')); return null; @@ -96,7 +98,7 @@ describe('wrapInTestApp', () => { it('should allow custom API implementations', async () => { const mockErrorApi = new MockErrorApi({ collect: true }); - const A: FC<{}> = () => { + const A = () => { const errorApi = useApi(errorApiRef); useEffect(() => { errorApi.post(new Error('NOPE')); @@ -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 182de1ef63..05cd0b8fd4 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -14,13 +14,16 @@ * limitations under the License. */ -import React, { ComponentType, ReactNode, FC, ReactElement } from 'react'; +import React, { ComponentType, ReactNode, ReactElement } from 'react'; import { MemoryRouter } from 'react-router'; import { Route } from 'react-router-dom'; import { lightTheme } from '@backstage/theme'; import privateExports, { defaultSystemIcons, BootErrorPageProps, + RouteRef, + ExternalRouteRef, + attachComponentData, } from '@backstage/core-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from '@backstage/test-utils-core'; @@ -31,7 +34,7 @@ const { PrivateAppImpl } = privateExports; const NotFoundErrorPage = () => { throw new Error('Reached NotFound Page'); }; -const BootErrorPage: FC = ({ step, error }) => { +const BootErrorPage = ({ step, error }: BootErrorPageProps) => { throw new Error(`Reached BootError Page at step ${step} with error ${error}`); }; const Progress = () =>
; @@ -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 | ExternalRouteRef }; }; /** @@ -80,21 +99,31 @@ export function wrapInTestApp( }, ], defaultApis: mockApis, + bindRoutes: () => {}, }); let Wrapper: ComponentType; if (Component instanceof Function) { Wrapper = Component; } else { - Wrapper = (() => Component) as FC; + Wrapper = () => Component as React.ReactElement; } + const routeElements = Object.entries(options.mountedRoutes ?? {}).map( + ([path, routeRef]) => { + const Page = () =>
Mounted at {path}
; + attachComponentData(Page, 'core.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 08cf256c48..4ee58b2201 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/theme +## 0.2.3 + +### Patch Changes + +- c810082ae: Updates warning text color to align to updated `WarningPanel` styling + +## 0.2.2 + +### Patch Changes + +- 1665ae8bb: Add a little more padding in dense tables + ## 0.2.1 ### Patch Changes diff --git a/packages/theme/README.md b/packages/theme/README.md index 4b29738193..9855d6730d 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -4,7 +4,7 @@ This package provides the extended Material UI Theme(s) that power Backstage. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/theme diff --git a/packages/theme/package.json b/packages/theme/package.json index 18b75afff4..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.1", + "version": "0.2.3", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0" + "@backstage/cli": "^0.6.0" }, "files": [ "dist" diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 193f6961d6..37b8e3b47f 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -135,7 +135,7 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { borderBottom: 0, }, sizeSmall: { - padding: theme.spacing(1, 2, 1, 2.5), + padding: theme.spacing(1.5, 2, 1.5, 2.5), }, head: { wordBreak: 'break-word', 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/README.md b/plugins/README.md index 6651ba079f..385e71b6d2 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,14 +1,12 @@ # Plugins -Backstage is a single-page application composed of a set of plugins. +Backstage is a single-page application composed of a set of plugins. This folder holds numerous plugins that are managed by this repository. -Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/backstage/backstage/blob/master/docs/dls/design.md) we ensure the overall user experience stays consistent between plugins. +For more information about the plugin ecosystem, see the documentation here: -![plugin](../docs/assets/my-plugin_screenshot.png) +> https://backstage.io/docs/plugins/ -## Creating a plugin - -To create a plugin, follow the steps outlined [here](https://github.com/backstage/backstage/blob/master/docs/plugins/create-a-plugin.md). +You can also see the [Plugin Marketplace](https://backstage.io/plugins) for other open source plugins you can add to your Backstage instance. ## Suggesting a plugin diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 3b9c1951d4..480beeff16 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,164 @@ # @backstage/plugin-api-docs +## 0.4.7 + +### Patch Changes + +- d6593abe6: Remove domain column from `HasSystemsCard` and system from `HasComponentsCard`, + `HasSubcomponentsCard`, and `HasApisCard`. +- 437bac549: Make the description column in the catalog table and api-docs table use up as + much space as possible before hiding overflowing text. +- 5469a9761: Changes made in CatalogTable and ApiExplorerTable for using the OverflowTooltip component for truncating large description and showing tooltip on hover-over. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.4.6 + +### Patch Changes + +- 0af242b6d: Introduce new cards to `@backstage/plugin-catalog` that can be added to entity pages: + + - `EntityHasSystemsCard` to display systems of a domain. + - `EntityHasComponentsCard` to display components of a system. + - `EntityHasSubcomponentsCard` to display subcomponents of a subcomponent. + - In addition, `EntityHasApisCard` to display APIs of a system is added to `@backstage/plugin-api-docs`. + + `@backstage/plugin-catalog-react` now provides an `EntityTable` to build own cards for entities. + The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, + `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.4.5 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 9161531b2: Link register API to catalog-import plugin +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [87c0c53c2] + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + +## 0.4.1 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.4.0 + +### Minor Changes + +- 246799c7f: Stop exposing a custom router from the `api-docs` plugin. Instead, use the + widgets exported by the plugin to compose your custom entity pages. + + Instead of displaying the API definitions directly in the API tab of the + component, it now contains tables linking to the API entities. This also adds + new widgets to display relationships (bot provides & consumes relationships) + between components and APIs. + + See the changelog of `create-app` for a migration guide. + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.3.1 ### Patch Changes diff --git a/packages/catalog-model/examples-relative/apis/petstore-api.yaml b/plugins/api-docs/dev/example-api.yaml similarity index 100% rename from packages/catalog-model/examples-relative/apis/petstore-api.yaml rename to plugins/api-docs/dev/example-api.yaml 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 659ded8061..6d0fd2fdec 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.3.1", + "version": "0.4.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,15 @@ "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/api-docs" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", - "@kyma-project/asyncapi-react": "^0.14.2", + "@asyncapi/react-component": "^0.19.2", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/theme": "^0.2.3", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -40,11 +49,11 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx deleted file mode 100644 index e2148184b7..0000000000 --- a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.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 { ComponentEntity, Entity } from '@backstage/catalog-model'; -import { Progress } from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import React from 'react'; -import { - ApiDefinitionCard, - useComponentApiEntities, - useComponentApiNames, -} from '../../components'; - -type Props = { - entity: Entity; -}; - -export const EntityPageApi = ({ entity }: Props) => { - const apiNames = useComponentApiNames(entity as ComponentEntity); - - const { apiEntities, loading } = useComponentApiEntities({ - entity: entity as ComponentEntity, - }); - - if (loading) { - return ; - } - - return ( - - {apiNames.map(api => ( - - - - ))} - - ); -}; diff --git a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx b/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx deleted file mode 100644 index fbb8810088..0000000000 --- a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Button, makeStyles, Typography } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { CodeSnippet, EmptyState } from '@backstage/core'; - -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: example -spec: - type: service - lifecycle: production - owner: guest - providesApis: - - example-api -`; - -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); - -export const MissingImplementsApisEmptyState = () => { - const classes = useStyles(); - return ( - - Components can implement APIs that are displayed on this page. You - need to fill the providesApis field to enable this tool. - - } - action={ - <> - - Link an API to your component as shown in the highlighted example - below: - -
- -
- - - } - /> - ); -}; diff --git a/plugins/api-docs/src/catalog/Router.tsx b/plugins/api-docs/src/catalog/Router.tsx deleted file mode 100644 index 64c074fc46..0000000000 --- a/plugins/api-docs/src/catalog/Router.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 React from 'react'; -import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; -import { Route, Routes } from 'react-router'; -import { catalogRoute } from '../routes'; -import { EntityPageApi } from './EntityPageApi'; -import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; - -const isPluginApplicableToEntity = (entity: Entity) => { - // TODO: Also support RELATION_CONSUMES_API - return entity.relations?.some(r => r.type === RELATION_PROVIDES_API); -}; - -export const Router = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( - - } - /> - ) - - ); 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/ApiExplorerLayout.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx index 0720ecf45b..add886fead 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx @@ -14,20 +14,25 @@ * limitations under the License. */ -import { Header, Page } from '@backstage/core'; +import { Header, Page, useApi, configApiRef } from '@backstage/core'; import React from 'react'; type Props = { children?: React.ReactNode; }; - -export const ApiExplorerLayout = ({ children }: Props) => ( - -
- {children} - -); +export const ApiExplorerLayout = ({ children }: Props) => { + const configApi = useApi(configApiRef); + const generatedSubtitle = `${ + configApi.getOptionalString('organization.name') ?? 'Backstage' + } API Explorer`; + return ( + +
+ {children} + + ); +}; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 10495bb957..223734504b 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -15,8 +15,15 @@ */ import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + ApiProvider, + ApiRegistry, + storageApiRef, + ConfigApi, + configApiRef, + ConfigReader, +} from '@backstage/core'; +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'; @@ -50,6 +57,12 @@ describe('ApiCatalogPage', () => { Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; + const configApi: ConfigApi = new ConfigReader({ + organization: { + name: 'My Company', + }, + }); + const apiDocsConfig = { getApiDefinitionWidget: () => undefined, }; @@ -60,6 +73,7 @@ describe('ApiCatalogPage', () => { { // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { const { findByText } = renderWrapped(); - expect(await findByText(/Backstage API Explorer/)).toBeInTheDocument(); + expect(await findByText(/My Company API Explorer/)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 398268caf2..83d0bfc2ba 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -14,16 +14,24 @@ * limitations under the License. */ -import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { + Content, + ContentHeader, + SupportButton, + useApi, + useRouteRef, +} from '@backstage/core'; +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'; import { useAsync } from 'react-use'; +import { createComponentRouteRef } from '../../routes'; import { ApiExplorerTable } from '../ApiExplorerTable'; import { ApiExplorerLayout } from './ApiExplorerLayout'; export const ApiExplorerPage = () => { + const createComponentLink = useRouteRef(createComponentRouteRef); const catalogApi = useApi(catalogApiRef); const { loading, error, value: catalogResponse } = useAsync(() => { return catalogApi.getEntities({ filter: { kind: 'API' } }); @@ -33,14 +41,16 @@ export const ApiExplorerPage = () => { - + {createComponentLink && ( + + )} All your APIs { ), ); 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..5b9808e26d 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -14,55 +14,100 @@ * 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, + OverflowTooltip, 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', + render: ({ entity }) => ( + + ), + width: 'auto', }, { 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 +160,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 +205,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/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx new file mode 100644 index 0000000000..5d2978a198 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { ConsumedApisCard } from './ConsumedApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); + expect(getByText(/No Component consumes this API/i)).toBeInTheDocument(); + }); + + it('shows consumed APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_CONSUMES_API, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Consumed APIs')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx new file mode 100644 index 0000000000..adfa3cd34c --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.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 { + ApiEntity, + Entity, + RELATION_CONSUMES_API, +} from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: 'gridItem'; +}; + +export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_CONSUMES_API, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No Component consumes this API.{' '} + + Learn how to consume APIs. + + + } + columns={apiEntityColumns} + entities={entities as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx new file mode 100644 index 0000000000..7b97f0867e --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { HasApisCard } from './HasApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/No API is part of this system/i)).toBeInTheDocument(); + }); + + it('shows related APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx new file mode 100644 index 0000000000..c3e636b1f1 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.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 { ApiEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + TableColumn, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { createSpecApiTypeColumn } from './presets'; + +type Props = { + variant?: 'gridItem'; +}; + +const columns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + createSpecApiTypeColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; + +export const HasApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'API', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No API is part of this system.{' '} + + Learn how to add APIs. + + + } + columns={columns} + entities={entities as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx new file mode 100644 index 0000000000..0d49cc328d --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { ProvidedApisCard } from './ProvidedApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Provided APIs/i)).toBeInTheDocument(); + expect(getByText(/No component provides this API/i)).toBeInTheDocument(); + }); + + it('shows consumed APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_PROVIDES_API, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText(/Provided APIs/i)).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx new file mode 100644 index 0000000000..f02390b6d6 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.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 { + ApiEntity, + Entity, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: 'gridItem'; +}; + +export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_PROVIDES_API, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component provides this API.{' '} + + Learn how to provide APIs. + + + } + columns={apiEntityColumns} + entities={entities as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/index.ts b/plugins/api-docs/src/components/ApisCards/index.ts new file mode 100644 index 0000000000..9243bdedd2 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/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 { ConsumedApisCard } from './ConsumedApisCard'; +export { HasApisCard } from './HasApisCard'; +export { ProvidedApisCard } from './ProvidedApisCard'; diff --git a/plugins/api-docs/src/components/ApisCards/presets.tsx b/plugins/api-docs/src/components/ApisCards/presets.tsx new file mode 100644 index 0000000000..151cccf846 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/presets.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiEntity } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { EntityTable } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { ApiTypeTitle } from '../ApiDefinitionCard'; + +export function createSpecApiTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + render: entity => , + }; +} + +// TODO: This could be moved to plugin-catalog-react if we wouldn't have a +// special createSpecApiTypeColumn. But this is required to use ApiTypeTitle to +// resolve the display name of an entity. Is the display name really worth it? + +export const apiEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }), + EntityTable.columns.createSystemColumn(), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + createSpecApiTypeColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; 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/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx new file mode 100644 index 0000000000..7944b3f90a --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { ConsumingComponentsCard } from './ConsumingComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Consumers')).toBeInTheDocument(); + expect(getByText(/No component consumes this API/i)).toBeInTheDocument(); + }); + + it('shows consuming components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_API_CONSUMED_BY, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Consumers')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx new file mode 100644 index 0000000000..7e4d97fece --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + Entity, + RELATION_API_CONSUMED_BY, +} from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: 'gridItem'; +}; + +export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_CONSUMED_BY, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component consumes this API.{' '} + + Learn how to consume APIs. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx new file mode 100644 index 0000000000..ab1751b9ba --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { ProvidingComponentsCard } from './ProvidingComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Providers')).toBeInTheDocument(); + expect(getByText(/No component provides this API/i)).toBeInTheDocument(); + }); + + it('shows providing components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_API_PROVIDED_BY, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Providers')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx new file mode 100644 index 0000000000..d755d8b5af --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + Entity, + RELATION_API_PROVIDED_BY, +} from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: 'gridItem'; +}; + +export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_PROVIDED_BY, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component provides this API.{' '} + + Learn how to provide APIs. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ComponentsCards/index.ts b/plugins/api-docs/src/components/ComponentsCards/index.ts new file mode 100644 index 0000000000..e1c0e87198 --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/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 { ConsumingComponentsCard } from './ConsumingComponentsCard'; +export { ProvidingComponentsCard } from './ProvidingComponentsCard'; 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/index.ts b/plugins/api-docs/src/components/index.ts index cf9b189091..cfd985f47c 100644 --- a/plugins/api-docs/src/components/index.ts +++ b/plugins/api-docs/src/components/index.ts @@ -14,13 +14,9 @@ * limitations under the License. */ -export type { ApiDefinitionWidget } from './ApiDefinitionCard'; -export { - ApiDefinitionCard, - defaultDefinitionWidgets, -} from './ApiDefinitionCard'; -export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; -export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; -export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget'; -export { useComponentApiNames } from './useComponentApiNames'; -export { useComponentApiEntities } from './useComponentApiEntities'; +export * from './ApiDefinitionCard'; +export * from './ApisCards'; +export * from './AsyncApiDefinitionWidget'; +export * from './ComponentsCards'; +export * from './OpenApiDefinitionWidget'; +export * from './PlainApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/useComponentApiEntities.ts b/plugins/api-docs/src/components/useComponentApiEntities.ts deleted file mode 100644 index 9e5cbd968e..0000000000 --- a/plugins/api-docs/src/components/useComponentApiEntities.ts +++ /dev/null @@ -1,83 +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 { useAsyncRetry } from 'react-use'; -import { errorApiRef, useApi } from '@backstage/core'; -import { - ApiEntity, - ComponentEntity, - parseEntityName, -} from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { useComponentApiNames } from './useComponentApiNames'; - -export function useComponentApiEntities({ - entity, -}: { - entity: ComponentEntity; -}): { - loading: boolean; - apiEntities?: Map; - error?: Error; - retry: () => void; -} { - const catalogApi = useApi(catalogApiRef); - const errorApi = useApi(errorApiRef); - - const apiNames = useComponentApiNames(entity); - - const { loading, value: apiEntities, retry, error } = useAsyncRetry< - Map - >(async () => { - const resultMap = new Map(); - - await Promise.all( - apiNames.map(async name => { - try { - const apiEntityName = parseEntityName(name, { - defaultNamespace: entity.metadata.namespace, - defaultKind: 'API', - }); - - if (apiEntityName.kind !== 'API') { - throw new Error( - `Referenced entity of kind "${apiEntityName.kind}" as an API`, - ); - } - - const api = (await catalogApi.getEntityByName(apiEntityName)) as - | ApiEntity - | undefined; - - if (api) { - resultMap.set(api.metadata.name, api); - } - } catch (e) { - errorApi.post(e); - } - }), - ); - - return resultMap; - }, [catalogApi, entity]); - - return { - apiEntities, - loading, - error, - retry, - }; -} diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index dbb32cee7b..8bab7f28f4 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -14,6 +14,15 @@ * limitations under the License. */ -export * from './catalog'; export * from './components'; -export { plugin } from './plugin'; +export { + apiDocsPlugin, + apiDocsPlugin as plugin, + ApiExplorerPage, + EntityApiDefinitionCard, + EntityConsumedApisCard, + EntityConsumingComponentsCard, + EntityProvidedApisCard, + EntityProvidingComponentsCard, + EntityHasApisCard, +} 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..4956a48f18 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, + createComponentExtension, + createPlugin, + createRoutableExtension, +} from '@backstage/core'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; -import { rootRoute } from './routes'; +import { ApiExplorerPage as Page } from './components/ApiExplorerPage/ApiExplorerPage'; import { apiDocsConfigRef } from './config'; +import { createComponentRouteRef, rootRoute } from './routes'; -export const plugin = createPlugin({ +export const apiDocsPlugin = createPlugin({ id: 'api-docs', + routes: { + root: rootRoute, + }, apis: [ createApiFactory({ api: apiDocsConfigRef, @@ -37,7 +45,75 @@ export const plugin = createPlugin({ }, }), ], + externalRoutes: { + createComponent: createComponentRouteRef, + }, 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, + ), + }, + }), +); + +export const EntityHasApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/ApisCards').then(m => m.HasApisCard), + }, + }), +); diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index 6adff78e47..9ca11c59ee 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createRouteRef } from '@backstage/core'; +import { createExternalRouteRef, createRouteRef } from '@backstage/core'; const NoIcon = () => null; @@ -24,8 +24,7 @@ export const rootRoute = createRouteRef({ title: 'APIs', }); -export const catalogRoute = createRouteRef({ - icon: NoIcon, - path: '', - title: 'API', +export const createComponentRouteRef = createExternalRouteRef({ + id: 'create-component', + optional: true, }); diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index e92b886f2d..06ec3e150f 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,70 @@ # @backstage/plugin-app-backend +## 0.3.8 + +### Patch Changes + +- 1c06cb312: Clarify troubleshooting steps for schema serialization issues. +- Updated dependencies [a1f5e6545] + - @backstage/config@0.1.3 + +## 0.3.7 + +### Patch Changes + +- 727f0deec: Added a new `disableConfigInjection` option, which can be used to disable the configuration injection in environments where it can't be used. +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + +## 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 + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + ## 0.3.2 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 6d2fdcb67c..af5a1e2f3d 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.2", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/app-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/config-loader": "^0.4.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.5.3", + "@backstage/config-loader": "^0.5.1", + "@backstage/config": "^0.1.3", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -31,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.2", "@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..4fe383414f 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -84,13 +84,22 @@ 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 app bundle schema. If this error is unexpected you need to run `yarn build` in the app. ' + + `If that doesn't help you should make sure your config schema is correct and rebuild the app bundle again. ` + + `Caused by the following schema error, ${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 9bdd04783a..14d70ba1f5 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,198 @@ # @backstage/plugin-auth-backend +## 0.3.2 + +### Patch Changes + +- ec504e7b4: Fix for refresh token being lost during Microsoft login. +- Updated dependencies [bad21a085] +- Updated dependencies [a1f5e6545] + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.3.1 + +### Patch Changes + +- 92f01d75c: Refactored auth provider factories to accept options along with other internal refactoring of the auth providers. +- d9687c524: Fixed parsing of OIDC key timestamps when using SQLite. +- 3600ac3b0: Migrated the package from using moment to Luxon. #4278 +- Updated dependencies [16fb1d03a] +- Updated dependencies [491f3a0ec] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] + - @backstage/backend-common@0.5.4 + +## 0.3.0 + +### Minor Changes + +- 1deb31141: Remove undocumented scope (default) from the OIDC auth provider which was breaking some identity services. If your app relied on this scope, you can manually specify it by adding a new factory in `packages/app/src/apis.ts`: + + ``` + export const apis = [ + createApiFactory({ + api: oidcAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: OAuth2Icon, + }, + defaultScopes: [ + 'default', + 'openid', + 'email', + 'offline_access', + ], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + ]; + ``` + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 07bafa248: Add configurable `scope` for oauth2 auth provider. + + Some OAuth2 providers require certain scopes to facilitate a user sign-in using the Authorization Code flow. + This change adds the optional `scope` key to auth.providers.oauth2. An example is: + + ```yaml + auth: + providers: + oauth2: + development: + clientId: + $env: DEV_OAUTH2_CLIENT_ID + clientSecret: + $env: DEV_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: DEV_OAUTH2_AUTH_URL + tokenUrl: + $env: DEV_OAUTH2_TOKEN_URL + scope: saml-login-selector openid profile email + ``` + + This tells the OAuth 2.0 AS to perform a SAML login and return OIDC information include the `profile` + and `email` claims as part of the ID Token. + +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [72b96e880] +- Updated dependencies [5a5163519] + - @backstage/catalog-client@0.3.6 + - @backstage/backend-common@0.5.3 + +## 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 + +- cc046682e: fix bug in token expiration date + +## 0.2.7 + +### Patch Changes + +- 7b15cc271: Added configuration schema for the commonly used properties +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + - @backstage/catalog-client@0.3.4 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/catalog-client@0.3.3 + ## 0.2.5 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index e69bb5b86b..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 @@ -25,7 +25,7 @@ export AUTH_GOOGLE_CLIENT_ID=x export AUTH_GOOGLE_CLIENT_SECRET=x ``` -### Github +### GitHub #### Creating a GitHub OAuth application @@ -42,7 +42,7 @@ export AUTH_GITHUB_CLIENT_ID=x export AUTH_GITHUB_CLIENT_SECRET=x ``` -for github enterprise: +For GitHub Enterprise: ```bash export AUTH_GITHUB_CLIENT_ID=x @@ -50,7 +50,7 @@ export AUTH_GITHUB_CLIENT_SECRET=x export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://x ``` -### Gitlab +### GitLab #### Creating a GitLab OAuth application @@ -70,7 +70,7 @@ Follow this link, [Add new application](https://gitlab.com/-/profile/application ```bash export GITLAB_BASE_URL=https://gitlab.com -export AUTH_GITLAB_CLIENT_ID=x # Gitlab calls this the Application ID +export AUTH_GITLAB_CLIENT_ID=x # GitLab calls this the Application ID export AUTH_GITLAB_CLIENT_SECRET=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 new file mode 100644 index 0000000000..9850e05235 --- /dev/null +++ b/plugins/auth-backend/config.d.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. + */ + +export interface Config { + /** Configuration options for the auth plugin */ + auth?: { + /** + * The 'environment' attribute + * @visibility frontend + */ + environment?: string; + + session?: { + /** + * The secret attribute of session object. + * @visibility secret + */ + secret?: string; + }; + + /** + * The available auth-provider options and attributes + */ + providers?: { + google?: { + development: { [key: string]: string }; + }; + github?: { + development: { [key: string]: string }; + }; + gitlab?: { + development: { [key: string]: string }; + }; + saml?: { + entryPoint: string; + logoutUrl?: string; + issuer: string; + cert?: string; + privateKey?: string; + decryptionPvk?: string; + signatureAlgorithm?: 'sha256' | 'sha512'; + digestAlgorithm?: string; + }; + okta?: { + development: { [key: string]: string }; + }; + oauth2?: { + development: { + clientId: string; + clientSecret: string; + authorizationUrl: string; + tokenUrl: string; + scope?: string; + }; + }; + oidc?: { + development: { [key: string]: string }; + }; + auth0?: { + development: { [key: string]: string }; + }; + microsoft?: { + development: { [key: string]: string }; + }; + 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 ddca6a24b4..9742e872f6 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.5", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/auth-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,11 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-client": "^0.3.2", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/test-utils": "^0.1.8", "@types/express": "^4.17.6", + "@types/passport": "^1.0.3", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", @@ -38,8 +49,9 @@ "jose": "^1.27.1", "jwt-decode": "^3.1.0", "knex": "^0.21.6", - "moment": "^2.26.0", + "luxon": "^1.25.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", @@ -49,50 +61,29 @@ "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", - "passport-saml": "^1.3.3", + "passport-saml": "^2.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.2", "@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", - "msw": "^0.21.2", - "nock": "^13.0.5" + "@types/passport-strategy": "^0.2.35", + "@types/xml2js": "^0.4.7", + "msw": "^0.21.2" }, "files": [ "dist", - "migrations" + "migrations", + "config.d.ts" ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/auth-backend", - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "session": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "visibility": "secret" - } - } - } - } - } - } - } + "configSchema": "config.d.ts" } diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts index b22ab0ecda..8896a0d158 100644 --- a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts @@ -15,8 +15,8 @@ */ import Knex from 'knex'; -import moment from 'moment'; import { DatabaseKeyStore } from './DatabaseKeyStore'; +import { DateTime } from 'luxon'; function createDB() { const knex = Knex({ @@ -51,7 +51,11 @@ describe('DatabaseKeyStore', () => { const { items } = await store.listKeys(); expect(items).toEqual([{ createdAt: expect.anything(), key }]); - expect(Math.abs(items[0].createdAt.diff(moment(), 's'))).toBeLessThan(10); + expect( + Math.abs( + DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds, + ), + ).toBeLessThan(10); }); it('should remove stored keys', async () => { diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts index c24cac55be..dd2941a3d5 100644 --- a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts @@ -15,9 +15,9 @@ */ import Knex from 'knex'; -import { utc } from 'moment'; import { resolvePackagePath } from '@backstage/backend-common'; import { AnyJWK, KeyStore, StoredKey } from './types'; +import { DateTime } from 'luxon'; const migrationsDir = resolvePackagePath( '@backstage/plugin-auth-backend', @@ -27,7 +27,7 @@ const migrationsDir = resolvePackagePath( const TABLE = 'signing_keys'; type Row = { - created_at: Date; + created_at: Date; // row.created_at is a string after being returned from the database kid: string; key: string; }; @@ -36,6 +36,21 @@ type Options = { database: Knex; }; +const parseDate = (date: string | Date) => { + const parsedDate = + typeof date === 'string' + ? DateTime.fromSQL(date, { zone: 'UTC' }) + : DateTime.fromJSDate(date); + + if (!parsedDate.isValid) { + throw new Error( + `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, + ); + } + + return parsedDate.toJSDate(); +}; + export class DatabaseKeyStore implements KeyStore { static async create(options: Options): Promise { const { database } = options; @@ -66,7 +81,7 @@ export class DatabaseKeyStore implements KeyStore { return { items: rows.map(row => ({ key: JSON.parse(row.key), - createdAt: utc(row.created_at), + createdAt: parseDate(row.created_at), })), }; } 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..3dc1d777c9 --- /dev/null +++ b/plugins/auth-backend/src/identity/MemoryKeyStore.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 { KeyStore, AnyJWK, StoredKey } from './types'; +import { DateTime } from 'luxon'; + +export class MemoryKeyStore implements KeyStore { + private readonly keys = new Map(); + + async addKey(key: AnyJWK): Promise { + this.keys.set(key.kid, { + createdAt: DateTime.utc().toJSDate(), + 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 4303401ee6..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 }; @@ -87,7 +57,7 @@ describe('TokenFactory', () => { iat: expect.any(Number), exp: expect.any(Number), }); - expect(payload.exp).toBe(payload.iat + keyDurationSeconds * 1000); + expect(payload.exp).toBe(payload.iat + keyDurationSeconds); }); it('should generate new signing keys when the current one expires', async () => { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index c4c259ed22..1c6192d5c5 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import moment from 'moment'; import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types'; import { JSONWebKey, JWK, JWS } from 'jose'; import { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; +import { DateTime } from 'luxon'; const MS_IN_S = 1000; @@ -52,7 +52,7 @@ export class TokenFactory implements TokenIssuer { private readonly keyStore: KeyStore; private readonly keyDurationSeconds: number; - private keyExpiry?: moment.Moment; + private keyExpiry?: Date; private privateKeyPromise?: Promise; constructor(options: Options) { @@ -69,7 +69,7 @@ export class TokenFactory implements TokenIssuer { const sub = params.claims.sub; const aud = 'backstage'; const iat = Math.floor(Date.now() / MS_IN_S); - const exp = iat + this.keyDurationSeconds * MS_IN_S; + const exp = iat + this.keyDurationSeconds; this.logger.info(`Issuing token for ${sub}`); @@ -90,8 +90,10 @@ export class TokenFactory implements TokenIssuer { for (const key of keys) { // Allow for a grace period of another full key duration before we remove the keys from the database - const expireAt = key.createdAt.add(3 * this.keyDurationSeconds, 's'); - if (expireAt.isBefore()) { + const expireAt = DateTime.fromJSDate(key.createdAt).plus({ + seconds: 3 * this.keyDurationSeconds, + }); + if (expireAt < DateTime.local()) { expiredKeys.push(key); } else { validKeys.push(key); @@ -117,14 +119,21 @@ export class TokenFactory implements TokenIssuer { private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { - if (this.keyExpiry?.isAfter()) { + if ( + this.keyExpiry && + DateTime.fromJSDate(this.keyExpiry) > DateTime.local() + ) { return this.privateKeyPromise; } this.logger.info(`Signing key has expired, generating new key`); delete this.privateKeyPromise; } - this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds'); + this.keyExpiry = DateTime.utc() + .plus({ + seconds: this.keyDurationSeconds, + }) + .toJSDate(); const promise = (async () => { // This generates a new signing key to be used to sign tokens until the next key rotation const key = await JWK.generate('EC', 'P-256', { 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/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 827aba51ab..6e984d41cc 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -52,7 +52,7 @@ export type TokenIssuer = { */ export type StoredKey = { key: AnyJWK; - createdAt: moment.Moment; + createdAt: Date; }; /** 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/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index ec6aa1b6ba..4d4bcaf2bf 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -38,11 +38,14 @@ describe('CatalogIdentityClient', () => { client.findUser({ annotations: { key: 'value' } }); - expect(catalogApi.getEntities).toBeCalledWith({ - filter: { - kind: 'user', - 'metadata.annotations.key': 'value', + expect(catalogApi.getEntities).toBeCalledWith( + { + filter: { + kind: 'user', + 'metadata.annotations.key': 'value', + }, }, - }); + undefined, + ); }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 5bc4e5de21..e1a3553394 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -37,7 +37,10 @@ export class CatalogIdentityClient { * * Throws a NotFoundError or ConflictError if 0 or multiple users are found. */ - async findUser(query: UserQuery): Promise { + async findUser( + query: UserQuery, + options?: { token?: string }, + ): Promise { const filter: Record = { kind: 'user', }; @@ -45,7 +48,7 @@ export class CatalogIdentityClient { filter[`metadata.annotations.${key}`] = value; } - const { items } = await this.catalogApi.getEntities({ filter }); + const { items } = await this.catalogApi.getEntities({ filter }, options); if (items.length !== 1) { if (items.length > 1) { diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts index 22ffc7af88..e70aa0bfee 100644 --- a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts @@ -56,7 +56,9 @@ export const postMessageResponse = ( var originInfo = {'type': 'config_info', 'targetOrigin': origin}; (window.opener || window.parent).postMessage(originInfo, '*'); (window.opener || window.parent).postMessage(JSON.parse(authResponse), origin); - window.close(); + setTimeout(() => { + window.close(); + }, 100); // same as the interval of the core-api lib/loginPopup.ts (to address race conditions) `; const hash = crypto.createHash('sha256').update(script).digest('base64'); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index d2b31213f2..38e21f04e8 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -173,6 +173,7 @@ describe('OAuthAdapter', () => { const mockResponse = ({ cookie: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), } as unknown) as express.Response; await oauthProvider.logout(mockRequest, mockResponse); @@ -200,12 +201,13 @@ describe('OAuthAdapter', () => { } as unknown) as express.Request; const mockResponse = ({ - send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), } as unknown) as express.Response; await oauthProvider.refresh(mockRequest, mockResponse); - expect(mockResponse.send).toHaveBeenCalledTimes(1); - expect(mockResponse.send).toHaveBeenCalledWith({ + expect(mockResponse.json).toHaveBeenCalledTimes(1); + expect(mockResponse.json).toHaveBeenCalledWith({ ...mockResponseData, backstageIdentity: { id: mockResponseData.backstageIdentity.id, @@ -230,6 +232,7 @@ describe('OAuthAdapter', () => { const mockResponse = ({ send: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), } as unknown) as express.Response; await oauthProvider.refresh(mockRequest, mockResponse); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 62cb8e444d..d6454fd807 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -155,7 +155,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { // remove refresh token cookie before logout this.removeRefreshTokenCookie(res); } - res.send('logout!'); + res.status(200).send('logout!'); } async refresh(req: express.Request, res: express.Response): Promise { @@ -165,9 +165,11 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } if (!this.handlers.refresh || this.options.disableRefresh) { - res.send( - `Refresh token not supported for provider: ${this.options.providerId}`, - ); + res + .status(400) + .send( + `Refresh token not supported for provider: ${this.options.providerId}`, + ); return; } @@ -198,7 +200,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { this.setRefreshTokenCookie(res, response.providerInfo.refreshToken); } - res.send(response); + res.status(200).json(response); } catch (error) { res.status(401).send(`${error.message}`); } diff --git a/plugins/auth-backend/src/lib/oauth/index.ts b/plugins/auth-backend/src/lib/oauth/index.ts index 564a0e1e7c..bf3e0658e4 100644 --- a/plugins/auth-backend/src/lib/oauth/index.ts +++ b/plugins/auth-backend/src/lib/oauth/index.ts @@ -25,4 +25,5 @@ export type { OAuthState, OAuthStartRequest, OAuthRefreshRequest, + OAuthResult, } from './types'; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index b2b7915a4e..dd477388bf 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import { Profile as PassportProfile } from 'passport'; import { AuthResponse, RedirectInfo } from '../../providers/types'; /** @@ -35,6 +36,17 @@ export type OAuthProviderOptions = { callbackUrl: string; }; +export type OAuthResult = { + fullProfile: PassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + export type OAuthResponse = AuthResponse; export type OAuthProviderInfo = { diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts index f7a22e9ad9..c27eebbd9a 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts @@ -87,7 +87,7 @@ describe('PassportStrategyHelper', () => { expect(spyAuthenticate).toBeCalledTimes(1); await expect(frameHandlerStrategyPromise).resolves.toStrictEqual( expect.objectContaining({ - response: { accessToken: 'ACCESS_TOKEN' }, + result: { accessToken: 'ACCESS_TOKEN' }, privateInfo: { refreshToken: 'REFRESH_TOKEN' }, }), ); diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 28f15ee9e8..8819da0a2f 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -39,7 +39,7 @@ export const makeProfileInfo = ( } let picture: string | undefined = undefined; - if (profile.photos) { + if (profile.photos && profile.photos.length > 0) { const [firstPhoto] = profile.photos; picture = firstPhoto.value; } @@ -80,15 +80,15 @@ export const executeRedirectStrategy = async ( }); }; -export const executeFrameHandlerStrategy = async ( +export const executeFrameHandlerStrategy = async ( req: express.Request, providerStrategy: passport.Strategy, ) => { - return new Promise<{ response: T; privateInfo: PrivateInfo }>( + return new Promise<{ result: Result; privateInfo: PrivateInfo }>( (resolve, reject) => { const strategy = Object.create(providerStrategy); - strategy.success = (response: any, privateInfo: any) => { - resolve({ response, privateInfo }); + strategy.success = (result: any, privateInfo: any) => { + resolve({ result, privateInfo }); }; strategy.fail = ( info: { type: 'success' | 'error'; message?: string }, @@ -190,19 +190,17 @@ type ProviderStrategy = { export const executeFetchUserProfileStrategy = async ( providerStrategy: passport.Strategy, accessToken: string, - idToken?: string, -): Promise => { +): Promise => { return new Promise((resolve, reject) => { const anyStrategy = (providerStrategy as unknown) as ProviderStrategy; anyStrategy.userProfile( accessToken, - (error: Error, passportProfile: passport.Profile) => { + (error: Error, rawProfile: passport.Profile) => { if (error) { reject(error); + } else { + resolve(rawProfile); } - - const profile = makeProfileInfo(passportProfile, idToken); - resolve(profile); }, ); }); diff --git a/plugins/auth-backend/src/providers/auth0/index.ts b/plugins/auth-backend/src/providers/auth0/index.ts index 87c9aceaa4..480552a91d 100644 --- a/plugins/auth-backend/src/providers/auth0/index.ts +++ b/plugins/auth-backend/src/providers/auth0/index.ts @@ -15,3 +15,4 @@ */ export { createAuth0Provider } from './provider'; +export type { Auth0ProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index 8772842e2a..2a2f743d12 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -26,6 +26,7 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, + OAuthResult, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -61,20 +62,16 @@ export class Auth0AuthProvider implements OAuthHandlers { accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); done( undefined, { - providerInfo: { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, + fullProfile, + accessToken, + refreshToken, + params, }, { refreshToken, @@ -96,13 +93,23 @@ export class Auth0AuthProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); + const profile = makeProfileInfo(result.fullProfile, result.params.id_token); + return { - response: await this.populateIdentity(response), + response: await this.populateIdentity({ + profile, + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + }), refreshToken: privateInfo.refreshToken, }; } @@ -114,11 +121,11 @@ export class Auth0AuthProvider implements OAuthHandlers { req.scope, ); - const profile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(fullProfile, params.id_token); return this.populateIdentity({ providerInfo: { @@ -148,28 +155,29 @@ export class Auth0AuthProvider implements OAuthHandlers { } } -export const createAuth0Provider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const domain = envConfig.getString('domain'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type Auth0ProviderOptions = {}; - const provider = new Auth0AuthProvider({ - clientId, - clientSecret, - callbackUrl, - domain, - }); +export const createAuth0Provider = ( + _options?: Auth0ProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const domain = envConfig.getString('domain'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: true, - providerId, - tokenIssuer, + const provider = new Auth0AuthProvider({ + clientId, + clientSecret, + callbackUrl, + domain, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); }); - }); +}; 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..cd6d41f580 --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/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 { createAwsAlbProvider } from './provider'; +export type { AwsAlbProviderOptions } 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..4a2b0f017c --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -0,0 +1,180 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { 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 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 = ({ + end: jest.fn(), + header: () => jest.fn(), + json: jest.fn().mockReturnThis(), + status: jest.fn(), + } 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(mockResponse.json).toHaveBeenCalledWith({ + 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(mockResponse.status).toHaveBeenCalledWith(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(mockResponse.status).toHaveBeenCalledWith(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(mockResponse.status).toHaveBeenCalledWith(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(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + }); + }); +}); 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..5b0bb8a33f --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -0,0 +1,133 @@ +/* + * 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.json(resolvedEntity); + } catch (e) { + this.logger.error('exception occurred during JWT processing', e); + res.status(401); + res.end(); + } + } else { + res.status(401); + res.end(); + } + } + + 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 type AwsAlbProviderOptions = {}; + +export const createAwsAlbProvider = (_options?: AwsAlbProviderOptions) => { + return ({ + 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..670dc84eec 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -25,16 +25,18 @@ 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, - github: createGithubProvider, - gitlab: createGitlabProvider, - saml: createSamlProvider, - okta: createOktaProvider, - auth0: createAuth0Provider, - microsoft: createMicrosoftProvider, - oauth2: createOAuth2Provider, - oidc: createOidcProvider, - onelogin: createOneLoginProvider, + google: createGoogleProvider(), + github: createGithubProvider(), + gitlab: createGitlabProvider(), + saml: createSamlProvider(), + okta: createOktaProvider(), + auth0: createAuth0Provider(), + microsoft: createMicrosoftProvider(), + oauth2: createOAuth2Provider(), + oidc: createOidcProvider(), + onelogin: createOneLoginProvider(), + awsalb: createAwsAlbProvider(), }; diff --git a/plugins/auth-backend/src/providers/github/index.ts b/plugins/auth-backend/src/providers/github/index.ts index 60ad6998b7..f2ba73c2b9 100644 --- a/plugins/auth-backend/src/providers/github/index.ts +++ b/plugins/auth-backend/src/providers/github/index.ts @@ -15,3 +15,4 @@ */ export { createGithubProvider } from './provider'; +export type { GithubProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts index 61d4bb887f..6394ed9f2a 100644 --- a/plugins/auth-backend/src/providers/github/provider.test.ts +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -14,13 +14,31 @@ * limitations under the License. */ +import { Profile as PassportProfile } from 'passport'; import { GithubAuthProvider } from './provider'; +import * as helpers from '../../lib/passport'; +import { OAuthResult } from '../../lib/oauth'; + +const mockFrameHandler = (jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown) as jest.MockedFunction< + () => Promise<{ + result: Omit & { params: { scope: string } }; + }> +>; describe('GithubAuthProvider', () => { + const provider = new GithubAuthProvider({ + callbackUrl: 'mock', + clientId: 'mock', + clientSecret: 'mock', + }); + describe('should transform to type OAuthResponse', () => { - it('when all fields are present, it should be able to map them', () => { + it('when all fields are present, it should be able to map them', async () => { const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const rawProfile = { + const fullProfile = { id: 'uid-123', username: 'jimmymarkum', provider: 'github', @@ -59,18 +77,17 @@ describe('GithubAuthProvider', () => { 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', }, }; - expect( - GithubAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ), - ).toEqual(expected); + + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile, accessToken, params }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); }); - it('when "email" is missing, it should be able to create the profile without it', () => { + it('when "email" is missing, it should be able to create the profile without it', async () => { const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const rawProfile = { + const fullProfile = ({ id: 'uid-123', username: 'jimmymarkum', provider: 'github', @@ -82,7 +99,7 @@ describe('GithubAuthProvider', () => { 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', }, ], - }; + } as unknown) as PassportProfile; const params = { scope: 'read:scope', @@ -105,18 +122,16 @@ describe('GithubAuthProvider', () => { }, }; - expect( - GithubAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ), - ).toEqual(expected); + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile, accessToken, params }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); }); - it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', () => { + it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', async () => { const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const rawProfile = { + const fullProfile = ({ id: 'uid-123', username: 'jimmymarkum', provider: 'github', @@ -128,7 +143,7 @@ describe('GithubAuthProvider', () => { 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', }, ], - }; + } as unknown) as PassportProfile; const params = { scope: 'read:scope', @@ -150,19 +165,17 @@ describe('GithubAuthProvider', () => { }, }; - expect( - GithubAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ), - ).toEqual(expected); + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile, accessToken, params }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); }); - it('when "photos" is missing, it should be able to create the profile without it', () => { + it('when "photos" is missing, it should be able to create the profile without it', async () => { const accessToken = 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe'; - const rawProfile = { + const fullProfile = { id: 'ipd12039', username: 'daveboyle', provider: 'gitlab', @@ -195,13 +208,11 @@ describe('GithubAuthProvider', () => { }, }; - expect( - GithubAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ), - ).toEqual(expected); + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile, accessToken, params }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); }); }); }); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 6e3f9a6000..2b0db9c2d9 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -27,12 +27,11 @@ import { OAuthAdapter, OAuthProviderOptions, OAuthHandlers, - OAuthResponse, OAuthEnvironmentHandler, OAuthStartRequest, encodeState, + OAuthResult, } from '../../lib/oauth'; -import passport from 'passport'; export type GithubAuthProviderOptions = OAuthProviderOptions & { tokenUrl?: string; @@ -43,55 +42,6 @@ export type GithubAuthProviderOptions = OAuthProviderOptions & { export class GithubAuthProvider implements OAuthHandlers { private readonly _strategy: GithubStrategy; - static transformPassportProfile(rawProfile: any): passport.Profile { - const profile: passport.Profile = { - id: rawProfile.username, - username: rawProfile.username, - provider: rawProfile.provider, - displayName: rawProfile.displayName || rawProfile.username, - photos: rawProfile.photos, - emails: rawProfile.emails, - }; - - return profile; - } - - static transformOAuthResponse( - accessToken: string, - rawProfile: any, - params: any = {}, - ): OAuthResponse { - const passportProfile = GithubAuthProvider.transformPassportProfile( - rawProfile, - ); - - const profile = makeProfileInfo(passportProfile, params.id_token); - const providerInfo = { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - idToken: params.id_token, - }; - - // GitHub provides an id numeric value (123) - // as a fallback - const id = passportProfile!.id; - - if (params.expires_in) { - providerInfo.expiresInSeconds = params.expires_in; - } - if (params.id_token) { - providerInfo.idToken = params.id_token; - } - return { - providerInfo, - profile, - backstageIdentity: { - id, - }, - }; - } - constructor(options: GithubAuthProviderOptions) { this._strategy = new GithubStrategy( { @@ -104,17 +54,12 @@ export class GithubAuthProvider implements OAuthHandlers { }, ( accessToken: any, - _: any, + _refreshToken: any, params: any, - rawProfile: any, - done: PassportDoneCallback, + fullProfile: any, + done: PassportDoneCallback, ) => { - const oauthResponse = GithubAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ); - done(undefined, oauthResponse); + done(undefined, { fullProfile, params, accessToken }); }, ); } @@ -127,51 +72,73 @@ export class GithubAuthProvider implements OAuthHandlers { } async handler(req: express.Request) { - const { response } = await executeFrameHandlerStrategy( - req, - this._strategy, + const { + result: { fullProfile, accessToken, params }, + } = await executeFrameHandlerStrategy(req, this._strategy); + + const profile = makeProfileInfo( + { + ...fullProfile, + id: fullProfile.username || fullProfile.id, + displayName: + fullProfile.displayName || fullProfile.username || fullProfile.id, + }, + params.id_token, ); - return { response }; + return { + response: { + profile, + providerInfo: { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + backstageIdentity: { + id: fullProfile.username || fullProfile.id, + }, + }, + }; } } -export const createGithubProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const enterpriseInstanceUrl = envConfig.getOptionalString( - 'enterpriseInstanceUrl', - ); - const authorizationUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/authorize` - : undefined; - const tokenUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/access_token` - : undefined; - const userProfileUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/api/v3/user` - : undefined; - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type GithubProviderOptions = {}; - const provider = new GithubAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenUrl, - userProfileUrl, - authorizationUrl, - }); +export const createGithubProvider = ( + _options?: GithubProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const enterpriseInstanceUrl = envConfig.getOptionalString( + 'enterpriseInstanceUrl', + ); + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: true, - persistScopes: true, - providerId, - tokenIssuer, + const provider = new GithubAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenUrl, + userProfileUrl, + authorizationUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + persistScopes: true, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/gitlab/index.ts b/plugins/auth-backend/src/providers/gitlab/index.ts index 7fba2dd95c..0a516021cd 100644 --- a/plugins/auth-backend/src/providers/gitlab/index.ts +++ b/plugins/auth-backend/src/providers/gitlab/index.ts @@ -15,3 +15,4 @@ */ export { createGitlabProvider } from './provider'; +export type { GitlabProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.test.ts b/plugins/auth-backend/src/providers/gitlab/provider.test.ts index d4d1de17ec..b57d846f23 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.test.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.test.ts @@ -15,14 +15,21 @@ */ import { GitlabAuthProvider } from './provider'; +import * as helpers from '../../lib/passport'; +import { OAuthResult } from '../../lib/oauth'; + +const mockFrameHandler = (jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown) as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>; describe('GitlabAuthProvider', () => { - it('should transform to type OAuthResponse', () => { + it('should transform to type OAuthResponse', async () => { const tests = [ { - arguments: { + result: { accessToken: '19xasczxcm9n7gacn9jdgm19me', - rawProfile: { + fullProfile: { id: 'uid-123', username: 'jimmymarkum', provider: 'gitlab', @@ -58,10 +65,10 @@ describe('GitlabAuthProvider', () => { }, }, { - arguments: { + result: { accessToken: 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', - rawProfile: { + fullProfile: { id: 'ipd12039', username: 'daveboyle', provider: 'gitlab', @@ -74,6 +81,7 @@ describe('GitlabAuthProvider', () => { }, params: { scope: 'read_repository', + expires_in: 200, }, }, expect: { @@ -83,6 +91,7 @@ describe('GitlabAuthProvider', () => { providerInfo: { accessToken: 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + expiresInSeconds: 200, scope: 'read_repository', }, profile: { @@ -93,14 +102,16 @@ describe('GitlabAuthProvider', () => { }, ]; + const provider = new GitlabAuthProvider({ + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + baseUrl: 'mock', + }); for (const test of tests) { - expect( - GitlabAuthProvider.transformOAuthResponse( - test.arguments.accessToken, - test.arguments.rawProfile, - test.arguments.params, - ), - ).toEqual(test.expect); + mockFrameHandler.mockResolvedValueOnce({ result: test.result }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(test.expect); } }); }); diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index df6d2daeb7..630cad0554 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -31,8 +31,8 @@ import { OAuthEnvironmentHandler, OAuthStartRequest, encodeState, + OAuthResult, } from '../../lib/oauth'; -import passport from 'passport'; export type GitlabAuthProviderOptions = OAuthProviderOptions & { baseUrl: string; @@ -41,64 +41,6 @@ export type GitlabAuthProviderOptions = OAuthProviderOptions & { export class GitlabAuthProvider implements OAuthHandlers { private readonly _strategy: GitlabStrategy; - static transformPassportProfile(rawProfile: any): passport.Profile { - const profile: passport.Profile = { - id: rawProfile.id, - username: rawProfile.username, - provider: rawProfile.provider, - displayName: rawProfile.displayName, - }; - - if (rawProfile.emails && rawProfile.emails.length > 0) { - profile.emails = rawProfile.emails; - } - if (rawProfile.avatarUrl) { - profile.photos = [{ value: rawProfile.avatarUrl }]; - } - - return profile; - } - - static transformOAuthResponse( - accessToken: string, - rawProfile: any, - params: any = {}, - ): OAuthResponse { - const passportProfile = GitlabAuthProvider.transformPassportProfile( - rawProfile, - ); - - const profile = makeProfileInfo(passportProfile, params.id_token); - const providerInfo = { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - idToken: params.id_token, - }; - - // gitlab provides an id numeric value (123) - // as a fallback - let id = passportProfile!.id; - - if (profile.email) { - id = profile.email.split('@')[0]; - } - - if (params.expires_in) { - providerInfo.expiresInSeconds = params.expires_in; - } - if (params.id_token) { - providerInfo.idToken = params.id_token; - } - return { - providerInfo, - profile, - backstageIdentity: { - id, - }, - }; - } - constructor(options: GitlabAuthProviderOptions) { this._strategy = new GitlabStrategy( { @@ -109,17 +51,12 @@ export class GitlabAuthProvider implements OAuthHandlers { }, ( accessToken: any, - _: any, + _refreshToken: any, params: any, - rawProfile: any, - done: PassportDoneCallback, + fullProfile: any, + done: PassportDoneCallback, ) => { - const oauthResponse = GitlabAuthProvider.transformOAuthResponse( - accessToken, - rawProfile, - params, - ); - done(undefined, oauthResponse); + done(undefined, { fullProfile, params, accessToken }); }, ); } @@ -132,36 +69,74 @@ export class GitlabAuthProvider implements OAuthHandlers { } async handler(req: express.Request): Promise<{ response: OAuthResponse }> { - return await executeFrameHandlerStrategy( + const { result } = await executeFrameHandlerStrategy( req, this._strategy, ); + const { accessToken, params } = result; + const fullProfile = result.fullProfile as OAuthResult['fullProfile'] & { + avatarUrl?: string; + }; + + const profile = makeProfileInfo( + { + ...fullProfile, + photos: [ + ...(fullProfile.photos ?? []), + ...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []), + ], + }, + params.id_token, + ); + + // gitlab provides an id numeric value (123) + // as a fallback + let id = fullProfile.id; + if (profile.email) { + id = profile.email.split('@')[0]; + } + + return { + response: { + profile, + providerInfo: { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + idToken: params.id_token, + }, + backstageIdentity: { + id, + }, + }, + }; } } -export const createGitlabProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const baseUrl = audience || 'https://gitlab.com'; - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type GitlabProviderOptions = {}; - const provider = new GitlabAuthProvider({ - clientId, - clientSecret, - callbackUrl, - baseUrl, - }); +export const createGitlabProvider = ( + _options?: GitlabProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const baseUrl = audience || 'https://gitlab.com'; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: true, - providerId, - tokenIssuer, + const provider = new GitlabAuthProvider({ + clientId, + clientSecret, + callbackUrl, + baseUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts index b2cd85e6de..2615a2d8e5 100644 --- a/plugins/auth-backend/src/providers/google/index.ts +++ b/plugins/auth-backend/src/providers/google/index.ts @@ -15,3 +15,4 @@ */ export { createGoogleProvider } from './provider'; +export type { GoogleProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index ae4f5fabd0..a00644a90d 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -28,6 +28,7 @@ import { OAuthRefreshRequest, OAuthResponse, OAuthStartRequest, + OAuthResult, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -38,24 +39,28 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { AuthProviderFactory, RedirectInfo } from '../types'; +import { TokenIssuer } from '../../identity'; type PrivateInfo = { refreshToken: string; }; -export type GoogleAuthProviderOptions = OAuthProviderOptions & { +type Options = OAuthProviderOptions & { logger: Logger; identityClient: CatalogIdentityClient; + tokenIssuer: TokenIssuer; }; export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; private readonly logger: Logger; private readonly identityClient: CatalogIdentityClient; + private readonly tokenIssuer: TokenIssuer; - constructor(options: GoogleAuthProviderOptions) { + constructor(options: Options) { this.logger = options.logger; this.identityClient = options.identityClient; + this.tokenIssuer = options.tokenIssuer; // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( { @@ -70,20 +75,16 @@ export class GoogleAuthProvider implements OAuthHandlers { accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); done( undefined, { - providerInfo: { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, + fullProfile, + params, + accessToken, + refreshToken, }, { refreshToken, @@ -105,13 +106,23 @@ export class GoogleAuthProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); + const profile = makeProfileInfo(result.fullProfile, result.params.id_token); + return { - response: await this.populateIdentity(response), + response: await this.populateIdentity({ + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + }), refreshToken: privateInfo.refreshToken, }; } @@ -123,11 +134,11 @@ export class GoogleAuthProvider implements OAuthHandlers { req.scope, ); - const profile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(fullProfile, params.id_token); return this.populateIdentity({ providerInfo: { @@ -150,11 +161,17 @@ export class GoogleAuthProvider implements OAuthHandlers { } try { - const user = await this.identityClient.findUser({ - annotations: { - 'google.com/email': profile.email, - }, + const token = await this.tokenIssuer.issueToken({ + claims: { sub: 'backstage.io/auth-backend' }, }); + const user = await this.identityClient.findUser( + { + annotations: { + 'google.com/email': profile.email, + }, + }, + { token }, + ); return { ...response, @@ -174,30 +191,37 @@ export class GoogleAuthProvider implements OAuthHandlers { } } -export const createGoogleProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - logger, - tokenIssuer, - catalogApi, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type GoogleProviderOptions = {}; - const provider = new GoogleAuthProvider({ - clientId, - clientSecret, - callbackUrl, - logger, - identityClient: new CatalogIdentityClient({ catalogApi }), - }); +export const createGoogleProvider = ( + _options?: GoogleProviderOptions, +): AuthProviderFactory => { + return ({ + providerId, + globalConfig, + config, + logger, + tokenIssuer, + catalogApi, + }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + const provider = new GoogleAuthProvider({ + clientId, + clientSecret, + callbackUrl, + logger, + tokenIssuer, + identityClient: new CatalogIdentityClient({ catalogApi }), + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/microsoft/index.ts b/plugins/auth-backend/src/providers/microsoft/index.ts index 2e4abd2d2c..f6c2ce40cd 100644 --- a/plugins/auth-backend/src/providers/microsoft/index.ts +++ b/plugins/auth-backend/src/providers/microsoft/index.ts @@ -15,3 +15,4 @@ */ export { createMicrosoftProvider } from './provider'; +export type { MicrosoftProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index b0c3635be8..e6157c1a83 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -38,6 +38,7 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, + OAuthResult, } from '../../lib/oauth'; import got from 'got'; @@ -54,32 +55,6 @@ export type MicrosoftAuthProviderOptions = OAuthProviderOptions & { export class MicrosoftAuthProvider implements OAuthHandlers { private readonly _strategy: MicrosoftStrategy; - static transformAuthResponse( - accessToken: string, - params: any, - rawProfile: any, - photoURL: any, - ): OAuthResponse { - let passportProfile: passport.Profile = rawProfile; - passportProfile = { - ...passportProfile, - photos: [{ value: photoURL }], - }; - - const profile = makeProfileInfo(passportProfile, params.id_token); - const providerInfo = { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }; - - return { - providerInfo, - profile, - }; - } - constructor(options: MicrosoftAuthProviderOptions) { this._strategy = new MicrosoftStrategy( { @@ -94,22 +69,10 @@ export class MicrosoftAuthProvider implements OAuthHandlers { accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - this.getUserPhoto(accessToken) - .then(photoURL => { - const authResponse = MicrosoftAuthProvider.transformAuthResponse( - accessToken, - params, - rawProfile, - photoURL, - ); - done(undefined, authResponse, { refreshToken }); - }) - .catch(error => { - throw new Error(`Error processing auth response: ${error}`); - }); + done(undefined, { fullProfile, accessToken, params }, { refreshToken }); }, ); } @@ -124,15 +87,37 @@ export class MicrosoftAuthProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); - return { - response: await this.populateIdentity(response), - refreshToken: privateInfo.refreshToken, - }; + try { + const photoUrl = await this.getUserPhoto(result.accessToken); + + const profile = makeProfileInfo( + { + ...result.fullProfile, + photos: photoUrl ? [{ value: photoUrl }] : undefined, + }, + result.params.id_token, + ); + + return { + response: await this.populateIdentity({ + profile, + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + }), + refreshToken: privateInfo.refreshToken, + }; + } catch (error) { + throw new Error(`Error processing auth response: ${error}`); + } } async refresh(req: OAuthRefreshRequest): Promise { @@ -142,11 +127,11 @@ export class MicrosoftAuthProvider implements OAuthHandlers { req.scope, ); - const profile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(fullProfile, params.id_token); const photo = await this.getUserPhoto(accessToken); if (photo) { profile.picture = photo; @@ -205,32 +190,33 @@ export class MicrosoftAuthProvider implements OAuthHandlers { } } -export const createMicrosoftProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const tenantID = envConfig.getString('tenantId'); +export type MicrosoftProviderOptions = {}; - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; - const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; +export const createMicrosoftProvider = ( + _options?: MicrosoftProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantId = envConfig.getString('tenantId'); - const provider = new MicrosoftAuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - }); - }); +}; diff --git a/plugins/auth-backend/src/providers/oauth2/index.ts b/plugins/auth-backend/src/providers/oauth2/index.ts index e607aa10e1..97848f4319 100644 --- a/plugins/auth-backend/src/providers/oauth2/index.ts +++ b/plugins/auth-backend/src/providers/oauth2/index.ts @@ -15,3 +15,4 @@ */ export { createOAuth2Provider } from './provider'; +export type { OAuth2ProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index 04cb7670f3..fb347b836c 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -26,6 +26,7 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, + OAuthResult, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -44,6 +45,7 @@ type PrivateInfo = { export type OAuth2AuthProviderOptions = OAuthProviderOptions & { authorizationUrl: string; tokenUrl: string; + scope?: string; }; export class OAuth2AuthProvider implements OAuthHandlers { @@ -58,26 +60,22 @@ export class OAuth2AuthProvider implements OAuthHandlers { authorizationURL: options.authorizationUrl, tokenURL: options.tokenUrl, passReqToCallback: false as true, + scope: options.scope, }, ( accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); - done( undefined, { - providerInfo: { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, + fullProfile, + accessToken, + refreshToken, + params, }, { refreshToken, @@ -99,13 +97,23 @@ export class OAuth2AuthProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); + const profile = makeProfileInfo(result.fullProfile, result.params.id_token); + return { - response: await this.populateIdentity(response), + response: await this.populateIdentity({ + profile, + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + }), refreshToken: privateInfo.refreshToken, }; } @@ -122,11 +130,11 @@ export class OAuth2AuthProvider implements OAuthHandlers { refreshToken: updatedRefreshToken, } = refreshTokenResponse; - const profile = await executeFetchUserProfileStrategy( + const rawProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(rawProfile, params.id_token); return this.populateIdentity({ providerInfo: { @@ -156,30 +164,33 @@ export class OAuth2AuthProvider implements OAuthHandlers { } } -export const createOAuth2Provider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = envConfig.getString('authorizationUrl'); - const tokenUrl = envConfig.getString('tokenUrl'); +export type OAuth2ProviderOptions = {}; - const provider = new OAuth2AuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, - }); +export const createOAuth2Provider = ( + _options?: OAuth2ProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = envConfig.getString('authorizationUrl'); + const tokenUrl = envConfig.getString('tokenUrl'); + const scope = envConfig.getOptionalString('scope'); - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + const provider = new OAuth2AuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + scope, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index acacbde73d..63c530acfc 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,3 +15,4 @@ */ export { createOidcProvider } from './provider'; +export type { OidcProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index f2bd8dcb90..f44c32dc4c 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -14,15 +14,17 @@ * limitations under the License. */ +import { Config, ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; import express from 'express'; import { Session } from 'express-session'; -import nock from 'nock'; +import { JWK, JWT } from 'jose'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; -import { createOidcProvider, OidcAuthProvider } from './provider'; -import { JWT, JWK } from 'jose'; -import { AuthProviderFactoryOptions } from '../types'; -import { Config } from '@backstage/config'; import { OAuthAdapter } from '../../lib/oauth'; +import { AuthProviderFactoryOptions } from '../types'; +import { createOidcProvider, OidcAuthProvider } from './provider'; const issuerMetadata = { issuer: 'https://oidc.test', @@ -49,10 +51,24 @@ const clientMetadata = { }; describe('OidcAuthProvider', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('hit the metadata url', async () => { - const scope = nock('https://oidc.test') - .get('/.well-known/openid-configuration') - .reply(200, issuerMetadata); + const handler = jest.fn((_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ); + }); + worker.use( + rest.get('https://oidc.test/.well-known/openid-configuration', handler), + ); const provider = new OidcAuthProvider(clientMetadata); const { strategy } = ((await (provider as any).implementation) as any) as { strategy: { @@ -61,7 +77,7 @@ describe('OidcAuthProvider', () => { }; }; // Assert that the expected request to the metadaurl was made. - expect(scope.isDone()).toBeTruthy(); + expect(handler).toBeCalledTimes(1); const { _client, _issuer } = strategy; expect(_client.client_id).toBe(clientMetadata.clientId); expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); @@ -71,23 +87,53 @@ describe('OidcAuthProvider', () => { const jwt = { sub: 'alice', iss: 'https://oidc.test', + iat: Date.now(), aud: clientMetadata.clientId, exp: Date.now() + 10000, }; - const scope = nock('https://oidc.test') - .get('/.well-known/openid-configuration') - .reply(200, issuerMetadata) - .post('/as/token.oauth2') - .reply(200, { - id_token: JWT.sign(jwt, JWK.None), - access_token: 'test', - authorization_signed_response_alg: 'HS256', - }) - .get('/idp/userinfo.openid') - .reply(200, { - sub: 'alice', - email: 'alice@oidc.test', - }); + const requestSequence: Array = []; + + // The array of expected requests executed by the provider handler + const requests: Array<{ + method: 'get' | 'post'; + url: string; + payload: object; + }> = [ + { + method: 'get', + url: 'https://oidc.test/.well-known/openid-configuration', + payload: issuerMetadata, + }, + { + method: 'post', + url: 'https://oidc.test/as/token.oauth2', + payload: { + id_token: JWT.sign(jwt, JWK.None), + access_token: 'test', + authorization_signed_response_alg: 'HS256', + }, + }, + { + method: 'get', + url: 'https://oidc.test/idp/userinfo.openid', + payload: { + sub: 'alice', + email: 'alice@oidc.test', + }, + }, + ]; + worker.use( + ...requests.map(r => { + return rest[r.method](r.url, (_req, res, ctx) => { + requestSequence.push(r.url); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(r.payload), + ); + }); + }), + ); const provider = new OidcAuthProvider(clientMetadata); const req = { method: 'GET', @@ -95,34 +141,38 @@ describe('OidcAuthProvider', () => { session: ({ 'oidc:oidc.test': 'test' } as any) as Session, } as express.Request; await provider.handler(req); - expect(scope.isDone()).toBeTruthy(); + expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url)); }); it('createOidcProvider', async () => { - const scope = nock('https://oidc.test') - .get('/.well-known/openid-configuration') - .reply(200, issuerMetadata); + const handler = jest.fn((_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ); + }); + worker.use( + rest.get('https://oidc.test/.well-known/openid-configuration', handler), + ); + const config: Config = new ConfigReader({ + testEnv: { + ...clientMetadata, + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + }, + }); const options = { globalConfig: { appUrl: 'https://oidc.test', baseUrl: 'https://oidc.test', }, - config: ({ - keys: jest.fn(() => ['test']), - getConfig: jest.fn(() => ({ - getString: (key: string) => { - const conf = { - ...clientMetadata, - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - } as any; - return conf[key] as string; - }, - })), - } as any) as Config, + config, } as AuthProviderFactoryOptions; - const provider = createOidcProvider(options) as OAuthAdapter; + const provider = createOidcProvider()(options) as OAuthAdapter; expect(provider.start).toBeDefined(); - await new Promise(resolve => process.nextTick(resolve)); // advance a tick to give nock a chance to intercept the request - expect(scope.isDone()).toBeTruthy(); + // Cast provider as any here to be able to inspect private members + await (provider as any).handlers.get('testEnv').handlers.implementation; + // Assert that the expected request to the metadaurl was made. + expect(handler).toBeCalledTimes(1); }); }); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 9e44a82c37..c7df8c1e7e 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -37,10 +37,10 @@ import { executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types'; +import { RedirectInfo, AuthProviderFactory } from '../types'; type PrivateInfo = { - refreshToken: string; + refreshToken?: string; }; type OidcImpl = { @@ -48,16 +48,24 @@ type OidcImpl = { client: Client; }; -export type OidcAuthProviderOptions = OAuthProviderOptions & { +type AuthResult = { + tokenset: TokenSet; + userinfo: UserinfoResponse; +}; + +export type Options = OAuthProviderOptions & { metadataUrl: string; + scope?: string; tokenSignedResponseAlg?: string; }; export class OidcAuthProvider implements OAuthHandlers { private readonly implementation: Promise; + private readonly scope?: string; - constructor(options: OidcAuthProviderOptions) { + constructor(options: Options) { this.implementation = this.setupStrategy(options); + this.scope = options.scope; } async start(req: OAuthStartRequest): Promise { @@ -65,22 +73,38 @@ export class OidcAuthProvider implements OAuthHandlers { return await executeRedirectStrategy(req, strategy, { accessType: 'offline', prompt: 'none', - scope: `${req.scope} default`, + scope: req.scope || this.scope || '', state: encodeState(req.state), }); } async handler( req: express.Request, - ): Promise<{ response: OAuthResponse; refreshToken: string }> { + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const strategyResponse = await executeFrameHandlerStrategy< + AuthResult, PrivateInfo >(req, strategy); - + const { + result: { userinfo, tokenset }, + privateInfo, + } = strategyResponse; + const identityResponse = await this.populateIdentity({ + profile: { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }, + providerInfo: { + idToken: tokenset.id_token, + accessToken: tokenset.access_token || '', + scope: tokenset.scope || '', + expiresInSeconds: tokenset.expires_in, + }, + }); return { - response: await this.populateIdentity(response), + response: identityResponse, refreshToken: privateInfo.refreshToken, }; } @@ -105,9 +129,7 @@ export class OidcAuthProvider implements OAuthHandlers { }); } - private async setupStrategy( - options: OidcAuthProviderOptions, - ): Promise { + private async setupStrategy(options: Options): Promise { const issuer = await Issuer.discover(options.metadataUrl); const client = new issuer.Client({ client_id: options.clientId, @@ -115,6 +137,7 @@ export class OidcAuthProvider implements OAuthHandlers { redirect_uris: [options.callbackUrl], response_types: ['code'], id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', + scope: options.scope || '', }); const strategy = new OidcStrategy( @@ -125,27 +148,13 @@ export class OidcAuthProvider implements OAuthHandlers { ( tokenset: TokenSet, userinfo: UserinfoResponse, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { - const profile: ProfileInfo = { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }; - done( undefined, + { tokenset, userinfo }, { - providerInfo: { - idToken: tokenset.id_token || '', - accessToken: tokenset.access_token || '', - scope: tokenset.scope || '', - expiresInSeconds: tokenset.expires_in, - }, - profile, - }, - { - refreshToken: tokenset.refresh_token || '', + refreshToken: tokenset.refresh_token, }, ); }, @@ -170,32 +179,35 @@ export class OidcAuthProvider implements OAuthHandlers { } } -export const createOidcProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const metadataUrl = envConfig.getString('metadataUrl'); - const tokenSignedResponseAlg = envConfig.getString( - 'tokenSignedResponseAlg', - ); +export type OidcProviderOptions = {}; - const provider = new OidcAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenSignedResponseAlg, - metadataUrl, - }); +export const createOidcProvider = ( + _options?: OidcProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const metadataUrl = envConfig.getString('metadataUrl'); + const tokenSignedResponseAlg = envConfig.getString( + 'tokenSignedResponseAlg', + ); + const scope = envConfig.getOptionalString('scope'); - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + const provider = new OidcAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + metadataUrl, + scope, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts index 05cc398f43..47a2549587 100644 --- a/plugins/auth-backend/src/providers/okta/index.ts +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { createOktaProvider } from './provider'; +export type { OktaProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 84b258e489..6320230e67 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -23,6 +23,7 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, + OAuthResult, } from '../../lib/oauth'; import { Strategy as OktaStrategy } from 'passport-okta-oauth'; import passport from 'passport'; @@ -80,21 +81,16 @@ export class OktaAuthProvider implements OAuthHandlers { accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); - done( undefined, { - providerInfo: { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, + accessToken, + refreshToken, + params, + fullProfile, }, { refreshToken, @@ -116,13 +112,23 @@ export class OktaAuthProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); + const profile = makeProfileInfo(result.fullProfile, result.params.id_token); + return { - response: await this.populateIdentity(response), + response: await this.populateIdentity({ + profile, + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + }), refreshToken: privateInfo.refreshToken, }; } @@ -134,11 +140,11 @@ export class OktaAuthProvider implements OAuthHandlers { req.scope, ); - const profile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(fullProfile, params.id_token); return this.populateIdentity({ providerInfo: { @@ -167,28 +173,29 @@ export class OktaAuthProvider implements OAuthHandlers { } } -export const createOktaProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type OktaProviderOptions = {}; - const provider = new OktaAuthProvider({ - audience, - clientId, - clientSecret, - callbackUrl, - }); +export const createOktaProvider = ( + _options?: OktaProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + const provider = new OktaAuthProvider({ + audience, + clientId, + clientSecret, + callbackUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/onelogin/index.ts b/plugins/auth-backend/src/providers/onelogin/index.ts index 79af226cd1..60fd2ffca4 100644 --- a/plugins/auth-backend/src/providers/onelogin/index.ts +++ b/plugins/auth-backend/src/providers/onelogin/index.ts @@ -15,3 +15,4 @@ */ export { createOneLoginProvider } from './provider'; +export type { OneLoginProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts index 838b655d59..5cb0f84415 100644 --- a/plugins/auth-backend/src/providers/onelogin/provider.ts +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -25,6 +25,7 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, + OAuthResult, } from '../../lib/oauth'; import passport from 'passport'; import { @@ -41,14 +42,14 @@ type PrivateInfo = { refreshToken: string; }; -export type OneLoginProviderOptions = OAuthProviderOptions & { +export type Options = OAuthProviderOptions & { issuer: string; }; export class OneLoginProvider implements OAuthHandlers { private readonly _strategy: any; - constructor(options: OneLoginProviderOptions) { + constructor(options: Options) { this._strategy = new OneLoginStrategy( { issuer: options.issuer, @@ -61,21 +62,16 @@ export class OneLoginProvider implements OAuthHandlers { accessToken: any, refreshToken: any, params: any, - rawProfile: passport.Profile, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile, params.id_token); - done( undefined, { - providerInfo: { - idToken: params.id_token, - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, + accessToken, + refreshToken, + params, + fullProfile, }, { refreshToken, @@ -96,13 +92,23 @@ export class OneLoginProvider implements OAuthHandlers { async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken: string }> { - const { response, privateInfo } = await executeFrameHandlerStrategy< - OAuthResponse, + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, PrivateInfo >(req, this._strategy); + const profile = makeProfileInfo(result.fullProfile, result.params.id_token); + return { - response: await this.populateIdentity(response), + response: await this.populateIdentity({ + profile, + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + }), refreshToken: privateInfo.refreshToken, }; } @@ -114,11 +120,11 @@ export class OneLoginProvider implements OAuthHandlers { req.scope, ); - const profile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, - params.id_token, ); + const profile = makeProfileInfo(fullProfile, params.id_token); return this.populateIdentity({ providerInfo: { @@ -146,28 +152,29 @@ export class OneLoginProvider implements OAuthHandlers { } } -export const createOneLoginProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const issuer = envConfig.getString('issuer'); - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; +export type OneLoginProviderOptions = {}; - const provider = new OneLoginProvider({ - clientId, - clientSecret, - callbackUrl, - issuer, - }); +export const createOneLoginProvider = ( + _options?: OneLoginProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const issuer = envConfig.getString('issuer'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + const provider = new OneLoginProvider({ + clientId, + clientSecret, + callbackUrl, + issuer, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - }); +}; diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts index 582deb1608..597b20ee8d 100644 --- a/plugins/auth-backend/src/providers/saml/index.ts +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -15,3 +15,4 @@ */ export { createSamlProvider } from './provider'; +export type { SamlProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 7b663a5528..c3a91b3b77 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import { SamlConfig } from 'passport-saml/lib/passport-saml/types'; import { Strategy as SamlStrategy, Profile as SamlProfile, @@ -25,17 +26,17 @@ import { executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { - AuthProviderRouteHandlers, - ProfileInfo, - AuthProviderFactory, -} from '../types'; +import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity'; type SamlInfo = { - userId: string; - profile: ProfileInfo; + fullProfile: any; +}; + +type Options = SamlConfig & { + tokenIssuer: TokenIssuer; + appUrl: string; }; export class SamlAuthProvider implements AuthProviderRouteHandlers { @@ -43,11 +44,11 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { private readonly tokenIssuer: TokenIssuer; private readonly appUrl: string; - constructor(options: SAMLProviderOptions) { + constructor(options: Options) { this.appUrl = options.appUrl; this.tokenIssuer = options.tokenIssuer; this.strategy = new SamlStrategy({ ...options }, (( - profile: SamlProfile, + fullProfile: SamlProfile, done: PassportDoneCallback, ) => { // TODO: There's plenty more validation and profile handling to do here, @@ -55,13 +56,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { // for non-oauth auth flows. // TODO: This flow doesn't issue an identity token that can be used to validate // the identity of the user in other backends, which we need in some form. - done(undefined, { - userId: profile.nameID!, - profile: { - email: profile.email!, - displayName: profile.displayName as string, - }, - }); + done(undefined, { fullProfile }); }) as VerifyWithoutRequest); } @@ -75,11 +70,13 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res: express.Response, ): Promise { try { - const { - response: { userId, profile }, - } = await executeFrameHandlerStrategy(req, this.strategy); + const { result } = await executeFrameHandlerStrategy( + req, + this.strategy, + ); + + const id = result.fullProfile.nameID; - const id = userId; const idToken = await this.tokenIssuer.issueToken({ claims: { sub: id }, }); @@ -87,8 +84,11 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { return postMessageResponse(res, this.appUrl, { type: 'authorization_response', response: { + profile: { + email: result.fullProfile.email, + displayName: result.fullProfile.displayName, + }, providerInfo: {}, - profile, backstageIdentity: { id, idToken }, }, }); @@ -112,30 +112,36 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } } -type SAMLProviderOptions = { - entryPoint: string; - issuer: string; - path: string; - tokenIssuer: TokenIssuer; - appUrl: string; -}; +type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha512'; -export const createSamlProvider: AuthProviderFactory = ({ - providerId, - globalConfig, - config, - tokenIssuer, -}) => { - const url = new URL(globalConfig.baseUrl); - const entryPoint = config.getString('entryPoint'); - const issuer = config.getString('issuer'); - const opts = { - entryPoint, - issuer, - path: `${url.pathname}/${providerId}/handler/frame`, - tokenIssuer, - appUrl: globalConfig.appUrl, +export type SamlProviderOptions = {}; + +export const createSamlProvider = ( + _options?: SamlProviderOptions, +): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => { + 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'), + decryptionPvk: config.getOptionalString('decryptionPvk'), + signatureAlgorithm: config.getOptionalString('signatureAlgorithm') as + | SignatureAlgorithm + | undefined, + digestAlgorithm: config.getOptionalString('digestAlgorithm'), + + tokenIssuer, + 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); }; - - 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 935d52f116..42315b3802 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,246 @@ # @backstage/plugin-catalog-backend +## 0.6.3 + +### Patch Changes + +- 2499f6cde: Add support for assuming role in AWS integrations +- Updated dependencies [bad21a085] +- Updated dependencies [a1f5e6545] + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.6.2 + +### Patch Changes + +- Updated dependencies [16fb1d03a] +- Updated dependencies [491f3a0ec] +- Updated dependencies [491f3a0ec] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] + - @backstage/backend-common@0.5.4 + - @backstage/integration@0.5.0 + +## 0.6.1 + +### Patch Changes + +- 77ad0003a: Revert AWS SDK version to v2 +- d2441aee3: use child logger, if provided, to log single location refresh +- fb53eb7cb: Don't respond to a request twice if an entity has not been found. +- f3fbfb452: add indices on columns referring locations(id) +- 84364b35c: Added an option to scan GitHub for repositories using a new location type `github-discovery`. + Example: + + ```yaml + type: 'github-discovery', + target: + 'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml' + ``` + + You can use wildcards (`*`) as well. This will add `location` entities for each matching repository. + Currently though, you must specify the exact path of the `catalog.yaml` file in the repository. + +- 82b2c11b6: Refactored route response handling to use more explicit types and throw errors. +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + +## 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 + +- 5de26b9a6: Start warning about usage of deprecated location types, such as `github` +- 30d6c78fb: Added configuration schema for the commonly used properties +- 5084e5039: Updated the config schema + +## 0.5.0 + +### Minor Changes + +- 6b37c95bf: Write relations directly as part of batch add / update of entities. + + Slight change of the `CommonDatabase` contract: + + ## `addEntity` removed + + This method was unused by the core, and rendered unnecessary when `addEntities` + exists. + + If you were a user of `addEntity`, please call `addEntities` instead, with an + array of one element. + + ## `DbEntityRequest` has a new field `relations` + + This is the structure that is passed to `addEntities` and `updateEntity`. It + used to be the case that you needed to call `setRelations` separately, but now + this instead happens directly when you call `addEntities` or `updateEntity`. + + If you were using `addEntities` or `updateEntity` directly, please adapt your + code to add the `relations` array to each request. If you were calling + `setRelations` separately next to these methods, you no longer need to do so, + after adding the relations to the `DbEntityRequest`s. + +- ac3560b42: Remove `implementsApis` from `Component` entities. Deprecation happened in [#3449](https://github.com/backstage/backstage/pull/3449). + Use `providesApis` instead. + +### Patch Changes + +- 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 + `ldapOrg` provider. +- e7496dc3e: Break out GithubOrgReaderProcessor config into its own file for consistency with the other org processors. +- 8dd0a906d: Support `profile` of groups including `displayName` and `picture` in + `GithubOrgReaderProcessor`. Fixes the import of `description` for groups. +- 8c31c681c: Batch the writing of statuses after refreshes. This reduced the runtime on sqlite from 16s to 0.2s, and on pg from 60s to 1s on my machine, for the huge LDAP set. +- 7b98e7fee: Add index to foreign key columns. Postgres (and others) do not do this on the "source" side of a foreign key relation, which was what led to the slowness on large datasets. The full LDAP dataset ingestion now takes two minutes, which is not optimal yet but still a huge improvement over before when it basically never finished :) +- 0097057ed: Support `profile` of groups including `displayName` and `email` in + `MicrosoftGraphOrgReaderProcessor`. Importing `picture` doesn't work yet, as + the Microsoft Graph API does not expose them correctly. +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.4.0 + +### Minor Changes + +- 83b6e0c1f: Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity. + + See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details. + +### Patch Changes + +- 6e8bb3ac0: leave unknown placeholder-lookalikes untouched in the catalog processing loop +- e708679d7: refreshAllLocations uses a child logger of the HigherOrderOperation with a meta `component` : `catalog-all-locations-refresh` +- 047c018c9: Batch the fetching of relations +- 38d63fbe1: Fix string template literal +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.3.0 ### Minor Changes diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts new file mode 100644 index 0000000000..32ff44457c --- /dev/null +++ b/plugins/catalog-backend/config.d.ts @@ -0,0 +1,399 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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'; + +export interface Config { + /** + * Configuration options for the catalog plugin. + */ + catalog?: { + /** + * Rules to apply to all catalog entities, from any location. + * + * An undefined list of matchers means match all, an empty list of + * matchers means match none. + * + * This is commonly used to put in what amounts to a whitelist of kinds + * that regular users of Backstage are permitted to register locations + * for. This can be used to stop them from registering yaml files + * describing for example a Group entity called "admin" that they make + * themselves members of, or similar. + */ + rules?: Array<{ + /** + * Allow entities of these particular kinds. + * + * E.g. ["Component", "API", "Template", "Location"] + */ + allow: Array; + }>; + + /** + * A set of static locations that the catalog shall always keep itself + * up-to-date with. This is commonly used for large, permanent integrations + * that are defined by the Backstage operators at an organization, rather + * than individual things that users register dynamically. + * + * These have (optional) rules of their own. These override what the global + * rules above specify. This way, you can prevent everybody from register + * e.g. User and Group entities, except for one or a few static locations + * that have those two kinds explicitly allowed. + * + * For example: + * + * ```yaml + * rules: + * - allow: [Component, API, Template, Location] + * locations: + * - type: url + * target: https://github.com/org/repo/blob/master/users.yaml + * rules: + * - allow: [User, Group] + * - type: url + * target: https://github.com/org/repo/blob/master/systems.yaml + * rules: + * - allow: [System] + * ``` + */ + locations?: Array<{ + /** + * The type of location, e.g. "url". + */ + type: string; + /** + * The target URL of the location, e.g. + * "https://github.com/org/repo/blob/master/users.yaml". + */ + target: string; + /** + * Optional extra rules that apply to this particular location. + * + * These override the global rules above. + */ + rules?: Array<{ + /** + * Allow entities of these particular kinds. + * + * E.g. ["Group", "User"] + */ + allow: Array; + }>; + }>; + + /** + * List of processor-specific options and attributes + */ + processors?: { + /** + * GithubOrgReaderProcessor configuration + */ + githubOrg?: { + /** + * The configuration parameters for each single GitHub org provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "https://github.com", with no trailing slash. + */ + target: string; + /** + * The base URL of the API of this provider, e.g. + * "https://api.github.com", with no trailing slash. + * + * May be omitted specifically for GitHub; then it will be deduced. + */ + apiBaseUrl?: string; + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous access is used. + * + * @visibility secret + */ + token?: string; + }>; + }; + + /** + * LdapOrgReaderProcessor configuration + */ + ldapOrg?: { + /** + * The configuration parameters for each single LDAP provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "ldaps://ds.example.net", with no trailing slash. + */ + target: string; + + /** + * The settings to use for the bind command. If none are specified, + * the bind command is not issued. + */ + bind?: { + /** + * The DN of the user to auth as. + * + * E.g. "uid=ldap-robot,ou=robots,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The secret of the user to auth as (its password). + * + * @visibility secret + */ + secret: string; + }; + + /** + * The settings that govern the reading and interpretation of users. + */ + users: { + /** + * The DN under which users are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "uid". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "uid". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. Defaults to + * "mail". + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.memberOf field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + }; + }; + + /** + * The settings that govern the reading and interpretation of groups. + */ + groups: { + /** + * The DN under which groups are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "cn". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "cn". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. Defaults to + * "description". + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.type field of the entity. Defaults to "groupType". + */ + type?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.parent field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.children field of the entity. Defaults to "member". + */ + members?: string; + }; + }; + }>; + }; + + /** + * AwsOrganizationCloudAccountProcessor configuration + */ + awsOrganization?: { + provider: { + /** + * The role to be assumed by this processor + * + */ + roleArn?: string; + }; + }; + + /** + * MicrosoftGraphOrgReaderProcessor configuration + */ + microsoftGraphOrg?: { + /** + * The configuration parameters for each single Microsoft Graph provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "https://graph.microsoft.com/v1.0", with no trailing slash. + */ + target: string; + /** + * The auth authority used. + * + * Default value "https://login.microsoftonline.com" + */ + authority?: string; + /** + * The tenant whose org data we are interested in. + */ + tenantId: string; + /** + * The OAuth client ID to use for authenticating requests. + */ + clientId: string; + /** + * The OAuth client secret to use for authenticating requests. + * + * @visibility secret + */ + clientSecret: string; + /** + * The filter to apply to extract users. + * + * E.g. "accountEnabled eq true and userType eq 'member'" + */ + userFilter?: string; + /** + * The filter to apply to extract groups. + * + * E.g. "securityEnabled eq false and mailEnabled eq true" + */ + groupFilter?: string; + }>; + }; + }; + }; +} diff --git a/plugins/catalog-backend/migrations/20200702153613_entities.js b/plugins/catalog-backend/migrations/20200702153613_entities.js index c97331796d..9acd7564db 100644 --- a/plugins/catalog-backend/migrations/20200702153613_entities.js +++ b/plugins/catalog-backend/migrations/20200702153613_entities.js @@ -20,16 +20,14 @@ * @param {import('knex')} knex */ exports.up = async function up(knex) { - // Drop constraints (Postgres) - try { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.dropForeign(['entity_id']); }); await knex.schema.alterTable('entities', table => { table.dropPrimary('entities_pkey'); }); - } catch (e) { - // SQLite does not support FK and PK, carry on } await knex.schema.alterTable('entities', table => { table.dropUnique([], 'entities_unique_name'); @@ -131,16 +129,14 @@ exports.up = async function up(knex) { * @param {import('knex')} knex */ exports.down = async function down(knex) { - // Drop constraints (Postgres) - try { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.dropForeign(['entity_id']); }); await knex.schema.alterTable('entities', table => { table.dropPrimary('entities_pkey'); }); - } catch (e) { - // SQLite does not support FK and PK, carry on } await knex.schema.alterTable('entities', table => { table.dropUnique([], 'entities_unique_name'); diff --git a/plugins/catalog-backend/migrations/20200807120600_entitySearch.js b/plugins/catalog-backend/migrations/20200807120600_entitySearch.js index 6e02975f92..b3a9673641 100644 --- a/plugins/catalog-backend/migrations/20200807120600_entitySearch.js +++ b/plugins/catalog-backend/migrations/20200807120600_entitySearch.js @@ -20,12 +20,11 @@ * @param {import('knex')} knex */ exports.up = async function up(knex) { - try { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.text('value').nullable().alter(); }); - } catch (e) { - // Sqlite does not support alter column. } }; @@ -33,11 +32,10 @@ exports.up = async function up(knex) { * @param {import('knex')} knex */ exports.down = async function down(knex) { - try { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.string('value').nullable().alter(); }); - } catch (e) { - // Sqlite does not support alter column. } }; diff --git a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js index 26cc98e74e..4c1ea76de4 100644 --- a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js +++ b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js @@ -30,12 +30,11 @@ exports.up = async function up(knex) { ), }); - try { + // SQLite does not support alter column + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities', table => { table.text('full_name').notNullable().alter(); }); - } catch (e) { - // SQLite does not support alter column, ignore } await knex.schema.alterTable('entities', table => { diff --git a/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js index fdabf72fce..a326a08a8b 100644 --- a/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js +++ b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js @@ -40,10 +40,7 @@ exports.up = async function up(knex) { table.dropColumn('spec'); }); - // SQLite does not support ALTER COLUMN. Note that we do not use the try/ - // catch method as in other migrations, because if the transaction is - // partially failed, it will further mess up the already messed-up - // statement below this. + // SQLite does not support ALTER COLUMN. if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities', table => { table.text('data').notNullable().alter(); diff --git a/plugins/catalog-backend/migrations/20201210185851_fk_index.js b/plugins/catalog-backend/migrations/20201210185851_fk_index.js new file mode 100644 index 0000000000..acf90b0a27 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201210185851_fk_index.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js b/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js new file mode 100644 index 0000000000..71f9500cf2 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + // We actually just want to widen columns, but can't do that while a + // view is dependent on them - so we just reconstruct it exactly as it was + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.text('message').alter(); + table.text('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.string('message').alter(); + table.string('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; diff --git a/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js b/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js new file mode 100644 index 0000000000..5591717c13 --- /dev/null +++ b/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.index('location_id', 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.index('location_id', 'update_log_location_id_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.dropIndex([], 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.dropIndex([], 'update_log_location_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 69f260d020..0925870506 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.3.0", + "version": "0.6.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/catalog-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,27 +29,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@octokit/graphql": "^4.5.6", + "@azure/msal-node": "^1.0.0-beta.3", + "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/integration": "^0.5.0", + "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", + "aws-sdk": "^2.840.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.0", + "git-url-parse": "^11.4.4", + "glob": "^7.1.6", "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", @@ -48,8 +59,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/test-utils": "^0.1.8", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", @@ -57,10 +68,13 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.21.2", + "sqlite3": "^5.0.0", "supertest": "^4.0.2" }, "files": [ "dist", - "migrations/**/*.{js,d.ts}" - ] + "migrations/**/*.{js,d.ts}", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 8d2138b3e5..5059d741f4 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -80,9 +80,10 @@ describe('DatabaseEntitiesCatalog', () => { 'kind=b,metadata.namespace=d,metadata.name=c', ), ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -113,9 +114,10 @@ describe('DatabaseEntitiesCatalog', () => { 'kind=b,metadata.namespace=d,metadata.name=c', ), ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(transaction.rollback).toBeCalledTimes(1); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -145,11 +147,7 @@ describe('DatabaseEntitiesCatalog', () => { }, }, }; - db.entities.mockResolvedValue([ - { - entity: dbEntity, - }, - ]); + db.entities.mockResolvedValue([{ entity: dbEntity }]); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -161,7 +159,7 @@ describe('DatabaseEntitiesCatalog', () => { ); expect(db.entities).toHaveBeenCalledTimes(2); - expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledTimes(1); expect(result).toEqual([ { entityId: 'u', @@ -237,12 +235,11 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -315,6 +312,7 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index f40e327eaa..b03183cf34 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { ConflictError, NotFoundError } from '@backstage/backend-common'; import { Entity, entityHasChanges, - EntityRelationSpec, generateUpdatedEntity, getEntityName, LOCATION_ANNOTATION, @@ -73,38 +72,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return items.map(i => i.entity); } - private async addOrUpdateEntity( - entity: Entity, - tx: Transaction, - locationId?: string, - ): Promise { - // Find a matching (by uid, or by compound name, depending on the given - // entity) existing entity, to know whether to update or add - const existing = entity.metadata.uid - ? await this.database.entityByUid(tx, entity.metadata.uid) - : await this.database.entityByName(tx, getEntityName(entity)); - - // If it's an update, run the algorithm for annotation merging, updating - // etag/generation, etc. - let response: DbEntityResponse; - if (existing) { - const updated = generateUpdatedEntity(existing.entity, entity); - response = await this.database.updateEntity( - tx, - { locationId, entity: updated }, - existing.entity.metadata.etag, - existing.entity.metadata.generation, - ); - } else { - const added = await this.database.addEntities(tx, [ - { locationId, entity }, - ]); - response = added[0]; - } - - return response.entity; - } - async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { const entityResponse = await this.database.entityByUid(tx, uid); @@ -135,13 +102,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }); } - /** - * Writes a number of entities efficiently to storage. - * - * @param entities Some entities - * @param options.locationId The location that they all belong to - * @param options.tx A database transaction to execute the queries in - */ async batchAddOrUpdateEntities( requests: EntityUpsertRequest[], options?: { @@ -150,122 +110,125 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { outputEntities?: boolean; }, ): Promise { - const locationId = options?.locationId; + // Group the requests by unique kind+namespace combinations. The reason for + // this is that the change detection and merging logic requires finding + // pre-existing versions of the entities in the database. Those queries are + // easier and faster to make if every batch revolves around a single kind- + // namespace pair. + const requestsByKindAndNamespace = groupBy(requests, ({ entity }) => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); - return await this.database.transaction(async tx => { - // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { - const name = getEntityName(entity); - return `${name.kind}:${name.namespace}`.toLowerCase(); - }); + // Go through the requests in reasonable batch sizes. Sometimes, sources + // 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(request => chunk(request, BATCH_SIZE)) + .flat(); - const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; - - for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupRequests[0].entity); - - // Go through the new entities in reasonable chunk sizes (sometimes, - // sources produce tens of thousands of entities, and those are too large - // batch sizes to reasonably send to the database) - for (const batch of chunk(groupRequests, BATCH_SIZE)) { - tasks.push( - limiter(async () => { - const first = serializeEntityRef(batch[0].entity); - const last = serializeEntityRef(batch[batch.length - 1].entity); - let modifiedEntityIds: EntityUpsertResponse[] = []; - - this.logger.debug( - `Considering batch ${first}-${last} (${batch.length} entries)`, + // 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 tasks = batches.map(batch => + limiter(async () => { + // Retry the batch write a few times to deal with contention + for (let attempt = 1; ; ++attempt) { + try { + return this.batchAddOrUpdateEntitiesSingleBatch(batch, options); + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, ); + } else { + throw e; + } + } + } + }), + ); - // Retry the batch write a few times to deal with contention - const context = { - kind, - namespace, - locationId, - }; - for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { - try { - const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( - batch, - context, - tx, - ); - if (toAdd.length) { - modifiedEntityIds.push( - ...(await this.batchAdd(toAdd, context, tx)), - ); - } - if (toUpdate.length) { - modifiedEntityIds.push( - ...(await this.batchUpdate(toUpdate, context, tx)), - ); - } - // TODO(Rugvip): We currently always update relations, but we - // likely want to figure out a way to avoid that - for (const { entity, relations } of toIgnore) { - const entityId = entity.metadata.uid; - if (entityId) { - await this.setRelations(entityId, relations, tx); - modifiedEntityIds.push({ entityId }); - } - } + const responses = await Promise.all(tasks); + return responses.flat(); + } - break; - } catch (e) { - if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { - this.logger.warn( - `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, - ); - } else { - throw e; - } - } - } + // Defines the actual logic of running a single batch. All of these share a + // common kind and namespace. + private async batchAddOrUpdateEntitiesSingleBatch( + batch: EntityUpsertRequest[], + options?: { + locationId?: string; + dryRun?: boolean; + outputEntities?: boolean; + }, + ) { + const { kind, namespace } = getEntityName(batch[0].entity); + const context = { + kind, + namespace, + locationId: options?.locationId, + }; - if (options?.outputEntities) { - const writtenEntities = await this.database.entities( - tx, - EntityFilters.ofMatchers({ - 'metadata.uid': modifiedEntityIds.map(e => e.entityId), - }), - ); + this.logger.debug( + `Considering batch ${serializeEntityRef( + batch[0].entity, + )}-${serializeEntityRef(batch[batch.length - 1].entity)} (${ + batch.length + } entries)`, + ); - modifiedEntityIds = writtenEntities.map(e => ({ - entityId: e.entity.metadata.uid!, - entity: e.entity, - })); - } + return this.database.transaction(async tx => { + const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( + batch, + context, + tx, + ); - return modifiedEntityIds; - }), - ); + let responses = new Array(); + if (toAdd.length) { + const items = await this.batchAdd(toAdd, context, tx); + responses.push(...items); + } + if (toUpdate.length) { + const items = await this.batchUpdate(toUpdate, context, tx); + responses.push(...items); + } + for (const { entity, relations } of toIgnore) { + // TODO(Rugvip): We currently always update relations, but we + // likely want to figure out a way to avoid that + const entityId = entity.metadata.uid; + if (entityId) { + await this.database.setRelations(tx, entityId, relations); + responses.push({ entityId }); } } - const entityUpserts = (await Promise.all(tasks)).flat(); + if (options?.outputEntities && responses.length > 0) { + const writtenEntities = await this.database.entities( + tx, + EntityFilters.ofMatchers({ + 'metadata.uid': responses.map(e => e.entityId), + }), + ); + responses = writtenEntities.map(e => ({ + entityId: e.entity.metadata.uid!, + entity: e.entity, + })); + } + // If this is only a dry run, cancel the database transaction even if it + // was successful. if (options?.dryRun) { - // If this is only a dry run, cancel the database transaction even if it was successful. await tx.rollback(); - this.logger.debug(`Performed successful dry run of adding entities`); } - return entityUpserts; + return responses; }); } - // Set the relations originating from an entity using the DB layer - private async setRelations( - originatingEntityId: string, - relations: EntityRelationSpec[], - tx: Transaction, - ): Promise { - await this.database.setRelations(tx, originatingEntityId, relations); - } - // Given a batch of entities that were just read from a location, take them // into consideration by comparing against the existing catalog entities and // produce the list of entities to be added, and the list of entities to be @@ -281,6 +244,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }> { const markTimestamp = process.hrtime(); + // Here we make use of the fact that all of the entities share kind and + // namespace within a batch const names = requests.map(({ entity }) => entity.metadata.name); const oldEntities = await this.database.entities( tx, @@ -320,7 +285,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // instead and call a dedicated batch update database method toUpdate.push(request); } else { - toIgnore.push(request); + // Use the existing entity to ensure that we're able to read it back by uid if needed + toIgnore.push({ ...request, entity: oldEntity }); } } @@ -344,22 +310,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const res = await this.database.addEntities( tx, - requests.map(({ entity }) => ({ locationId, entity })), + requests.map(({ entity, relations }) => ({ + locationId, + entity, + relations, + })), ); - const entityIds = res.map(({ entity }) => ({ + const responses = res.map(({ entity }) => ({ entityId: entity.metadata.uid!, })); - for (const [index, { entityId }] of entityIds.entries()) { - await this.setRelations(entityId, requests[index].relations, tx); - } - this.logger.debug( `Added ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return entityIds; + return responses; } // Efficiently updates the given entities into storage, under the assumption @@ -370,19 +336,52 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); - const responseIds: EntityUpsertResponse[] = []; + const responses: EntityUpsertResponse[] = []; + // TODO(freben): Still not batched - for (const entity of requests) { - const res = await this.addOrUpdateEntity(entity.entity, tx, locationId); + for (const request of requests) { + const res = await this.addOrUpdateEntity(tx, request, locationId); const entityId = res.metadata.uid!; - responseIds.push({ entityId }); - await this.setRelations(entityId, entity.relations, tx); + responses.push({ entityId }); } this.logger.debug( `Updated ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return responseIds; + return responses; + } + + // TODO(freben): Incorporate this into batchUpdate which is the only caller + private async addOrUpdateEntity( + tx: Transaction, + { entity, relations }: EntityUpsertRequest, + locationId?: string, + ): Promise { + // Find a matching (by uid, or by compound name, depending on the given + // entity) existing entity, to know whether to update or add + const existing = entity.metadata.uid + ? await this.database.entityByUid(tx, entity.metadata.uid) + : await this.database.entityByName(tx, getEntityName(entity)); + + // If it's an update, run the algorithm for annotation merging, updating + // etag/generation, etc. + let response: DbEntityResponse; + if (existing) { + const updated = generateUpdatedEntity(existing.entity, entity); + response = await this.database.updateEntity( + tx, + { locationId, entity: updated, relations }, + existing.entity.metadata.etag, + existing.entity.metadata.generation, + ); + } else { + const added = await this.database.addEntities(tx, [ + { locationId, entity, relations }, + ]); + response = added[0]; + } + + return response.entity; } } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index 9ce53d2850..91245c0789 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -70,7 +70,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { async logUpdateSuccess( locationId: string, - entityName?: string, + entityName?: string | string[], ): Promise { await this.database.addLocationUpdateLogEvent( locationId, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 52bb6e95ab..b012ee3606 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -32,17 +32,30 @@ export type EntityUpsertResponse = { }; export type EntitiesCatalog = { + /** + * Fetch entities. + * + * @param filter A filter to apply when reading + */ entities(filter?: EntityFilter): Promise; + + /** + * Removes a single entity. + * + * @param uid The metadata.uid of the entity + */ removeEntityByUid(uid: string): Promise; /** * Writes a number of entities efficiently to storage. * - * @param entities Some entities - * @param locationId The location that they all belong to + * @param requests The entities and their relations + * @param options.locationId The location that they all belong to (default none) + * @param options.dryRun Whether to throw away the results (default false) + * @param options.outputEntities Whether to return the resulting entities (default false) */ batchAddOrUpdateEntities( - entities: EntityUpsertRequest[], + requests: EntityUpsertRequest[], options?: { locationId?: string; dryRun?: boolean; @@ -80,7 +93,10 @@ export type LocationsCatalog = { locations(): Promise; location(id: string): Promise; locationHistory(id: string): Promise; - logUpdateSuccess(locationId: string, entityName?: string): Promise; + logUpdateSuccess( + locationId: string, + entityName?: string | string[], + ): Promise; logUpdateFailure( locationId: string, error?: Error, diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index ffffd85f04..b319bb832d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -54,6 +54,7 @@ describe('CommonDatabase', () => { }, spec: { i: 'j' }, }, + relations: [], }; entityResponse = { @@ -136,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 => @@ -152,6 +169,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -159,6 +177,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, ]; await expect( @@ -174,6 +193,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -181,6 +201,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'nS1' }, }, + relations: [], }, ]; await expect( @@ -196,6 +217,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -203,6 +225,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns2' }, }, + relations: [], }, ]; await expect( @@ -285,7 +308,7 @@ describe('CommonDatabase', () => { db.addEntities(tx, [entityRequest]), ); const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion); expect(updated.entity.kind).toEqual(added.entity.kind); @@ -305,7 +328,7 @@ describe('CommonDatabase', () => { ); added.entity.metadata.name! = 'new!'; const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.metadata.name).toEqual('new!'); }); @@ -316,7 +339,11 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, 'garbage'), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + 'garbage', + ), ), ).rejects.toThrow(ConflictError); }); @@ -327,7 +354,12 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, undefined, 1e20), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + undefined, + 1e20, + ), ), ).rejects.toThrow(ConflictError); }); @@ -347,7 +379,10 @@ describe('CommonDatabase', () => { spec: { c: null }, }; await db.transaction(async tx => { - await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]); + await db.addEntities(tx, [ + { entity: e1, relations: [] }, + { entity: e2, relations: [] }, + ]); }); const result = await db.transaction(async tx => db.entities(tx)); expect(result.length).toEqual(2); @@ -385,7 +420,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -426,7 +461,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -566,8 +601,8 @@ describe('CommonDatabase', () => { const { id2: secondEntityId } = await db.transaction(async tx => { const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [ - { entity: entity1 }, - { entity: entity2 }, + { entity: entity1, relations: [] }, + { entity: entity2, relations: [] }, ]); const id1 = e1?.metadata?.uid!; const id2 = e2?.metadata?.uid!; @@ -665,7 +700,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 287756b0a8..2edc0ca5b8 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -95,35 +95,6 @@ export class CommonDatabase implements Database { } } - async addEntity( - txOpaque: Transaction, - request: DbEntityRequest, - ): Promise { - const tx = txOpaque as Knex.Transaction; - - if (request.entity.metadata.uid !== undefined) { - throw new InputError('May not specify uid for new entities'); - } else if (request.entity.metadata.etag !== undefined) { - throw new InputError('May not specify etag for new entities'); - } else if (request.entity.metadata.generation !== undefined) { - throw new InputError('May not specify generation for new entities'); - } - - const newEntity = lodash.cloneDeep(request.entity); - newEntity.metadata = { - ...newEntity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, - }; - - const newRow = this.toEntityRow(request.locationId, newEntity); - await tx('entities').insert(newRow); - await this.updateEntitiesSearch(tx, newRow.id, newEntity); - - return { locationId: request.locationId, entity: newEntity }; - } - async addEntities( txOpaque: Transaction, request: DbEntityRequest[], @@ -132,9 +103,10 @@ export class CommonDatabase implements Database { const result: DbEntityResponse[] = []; const entityRows: DbEntitiesRow[] = []; + const relationRows: DbEntitiesRelationsRow[] = []; const searchRows: DbEntitiesSearchRow[] = []; - for (const { entity, locationId } of request) { + for (const { entity, relations, locationId } of request) { if (entity.metadata.uid !== undefined) { throw new InputError('May not specify uid for new entities'); } else if (entity.metadata.etag !== undefined) { @@ -145,28 +117,27 @@ export class CommonDatabase implements Database { throw new InputError('May not specify relations for new entities'); } + const uid = generateEntityUid(); + const etag = generateEntityEtag(); + const generation = 1; const newEntity = { ...entity, metadata: { ...entity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, + uid, + etag, + generation, }, }; result.push({ entity: newEntity, locationId }); entityRows.push(this.toEntityRow(locationId, newEntity)); - searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity)); + relationRows.push(...this.toRelationRows(uid, relations)); + searchRows.push(...buildEntitySearch(uid, newEntity)); } await tx.batchInsert('entities', entityRows, BATCH_SIZE); - await tx('entities_search') - .whereIn( - 'entity_id', - entityRows.map(r => r.id), - ) - .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); await tx.batchInsert('entities_search', searchRows, BATCH_SIZE); return result; @@ -181,8 +152,7 @@ export class CommonDatabase implements Database { const tx = txOpaque as Knex.Transaction; const { uid } = request.entity.metadata; - - if (uid === undefined) { + if (!uid) { throw new InputError('Must specify uid when updating entities'); } @@ -193,40 +163,48 @@ export class CommonDatabase implements Database { if (oldRows.length !== 1) { throw new NotFoundError('No matching entity found'); } + const etag = oldRows[0].etag; + const generation = Number(oldRows[0].generation); - // Validate the old entity - const oldRow = oldRows[0]; - // The Number cast is here because sqlite reads it as a string, no matter - // what the table actually says - oldRow.generation = Number(oldRow.generation); - if (matchingEtag) { - if (matchingEtag !== oldRow.etag) { - throw new ConflictError( - `Etag mismatch, expected="${matchingEtag}" found="${oldRow.etag}"`, - ); - } + // Validate the old entity. The Number cast is here because sqlite reads it + // as a string, no matter what the table actually says. + if (matchingEtag && matchingEtag !== etag) { + throw new ConflictError( + `Etag mismatch, expected="${matchingEtag}" found="${etag}"`, + ); } - if (matchingGeneration) { - if (matchingGeneration !== oldRow.generation) { - throw new ConflictError( - `Generation mismatch, expected="${matchingGeneration}" found="${oldRow.generation}"`, - ); - } + if (matchingGeneration && matchingGeneration !== generation) { + throw new ConflictError( + `Generation mismatch, expected="${matchingGeneration}" found="${generation}"`, + ); } // Store the updated entity; select on the old etag to ensure that we do // not lose to another writer const newRow = this.toEntityRow(request.locationId, request.entity); const updatedRows = await tx('entities') - .where({ id: oldRow.id, etag: oldRow.etag }) + .where({ id: uid, etag }) .update(newRow); - - // If this happens, somebody else changed the entity just now if (updatedRows !== 1) { throw new ConflictError(`Failed to update entity`); } - await this.updateEntitiesSearch(tx, oldRow.id, request.entity); + const relationRows = this.toRelationRows(uid, request.relations); + await tx('entities_relations') + .where({ originating_entity_id: uid }) + .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); + + try { + const entries = buildEntitySearch(uid, request.entity); + await tx('entities_search') + .where({ entity_id: uid }) + .del(); + await tx.batchInsert('entities_search', entries, BATCH_SIZE); + } catch { + // ignore intentionally - if this happens, the entity was deleted before + // we got around to writing the entries + } return request; } @@ -271,7 +249,7 @@ export class CommonDatabase implements Database { .select('entities.*') .orderBy('full_name', 'asc'); - return Promise.all(rows.map(row => this.toEntityResponse(tx, row))); + return this.toEntityResponses(tx, rows); } async entityByName( @@ -290,7 +268,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async entityByUid( @@ -307,7 +285,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async removeEntityByUid(txOpaque: Transaction, uid: string): Promise { @@ -326,30 +304,12 @@ export class CommonDatabase implements Database { relations: EntityRelationSpec[], ): Promise { const tx = txOpaque as Knex.Transaction; + const relationRows = this.toRelationRows(originatingEntityId, relations); - // remove all relations that exist for the originating entity id. await tx('entities_relations') .where({ originating_entity_id: originatingEntityId }) .del(); - - const serializeName = (e: EntityName) => - `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - - const relationsRows: DbEntitiesRelationsRow[] = relations.map( - ({ source, target, type }) => ({ - originating_entity_id: originatingEntityId, - source_full_name: serializeName(source), - target_full_name: serializeName(target), - type, - }), - ); - - // TODO(blam): translate constraint failures to sane NotFoundError instead - await tx.batchInsert( - 'entities_relations', - deduplicateRelations(relationsRows), - BATCH_SIZE, - ); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); } async addLocation( @@ -370,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 { @@ -432,7 +398,7 @@ export class CommonDatabase implements Database { async addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise { // Remove log entries older than a day @@ -442,30 +408,19 @@ export class CommonDatabase implements Database { .where('created_at', '<', cutoff.toISOString()) .del(); - await this.database( - 'location_update_log', - ).insert({ - status, - location_id: locationId, - entity_name: entityName, - message, - }); - } + const items: Partial[] = [entityName] + .flat() + .map(n => ({ + status, + location_id: locationId, + entity_name: n, + message, + })); - private async updateEntitiesSearch( - tx: Knex.Transaction, - entityId: string, - data: Entity, - ): Promise { - try { - const entries = buildEntitySearch(entityId, data); - await tx('entities_search') - .where({ entity_id: entityId }) - .del(); - await tx('entities_search').insert(entries); - } catch { - // ignore intentionally - if this happens, the entity was deleted before - // we got around to writing the entries + for (const chunk of lodash.chunk(items, BATCH_SIZE)) { + await this.database( + 'location_update_log', + ).insert(chunk); } } @@ -494,31 +449,77 @@ export class CommonDatabase implements Database { }; } - private async toEntityResponse( - tx: Knex.Transaction, - row: DbEntitiesRow, - ): Promise { - const entity = JSON.parse(row.data) as Entity; - entity.metadata.uid = row.id; - entity.metadata.etag = row.etag; - entity.metadata.generation = Number(row.generation); // cast due to sqlite + private toRelationRows( + originatingEntityId: string, + relations: EntityRelationSpec[], + ): DbEntitiesRelationsRow[] { + const serializeName = (e: EntityName) => + `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - // TODO(Rugvip): This is here because it's simple for now, but we likely - // need to refactor this to be more efficient or introduce pagination. - const relations = await tx('entities_relations') - .where({ source_full_name: row.full_name }) - .orderBy(['type', 'target_full_name']) - .select(); - - entity.relations = deduplicateRelations(relations).map(r => ({ - target: parseEntityName(r.target_full_name), - type: r.type, + const rows = relations.map(({ source, target, type }) => ({ + originating_entity_id: originatingEntityId, + source_full_name: serializeName(source), + target_full_name: serializeName(target), + type, })); - return { - locationId: row.location_id || undefined, - entity, - }; + return deduplicateRelations(rows); + } + + private async toEntityResponses( + tx: Knex.Transaction, + rows: DbEntitiesRow[], + ): Promise { + // TODO(Rugvip): This is here because it's simple for now, but we likely + // need to refactor this to be more efficient or introduce pagination. + const relations = await this.getRelationsPerFullName( + tx, + rows.map(r => r.full_name), + ); + + const result = new Array(); + for (const row of rows) { + const entity = JSON.parse(row.data) as Entity; + entity.metadata.uid = row.id; + entity.metadata.etag = row.etag; + entity.metadata.generation = Number(row.generation); // cast due to sqlite + + entity.relations = (relations[row.full_name] ?? []).map(r => ({ + target: parseEntityName(r.target_full_name), + type: r.type, + })); + + result.push({ + locationId: row.location_id || undefined, + entity, + }); + } + + return result; + } + + // Returns a mapping from e.g. component:default/foo to the relations whose + // source_full_name matches that. + private async getRelationsPerFullName( + tx: Knex.Transaction, + sourceFullNames: string[], + ): Promise> { + const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500); + + const relations = new Array(); + for (const batch of batches) { + relations.push( + ...(await tx('entities_relations') + .whereIn('source_full_name', batch) + .orderBy(['type', 'target_full_name']) + .select()), + ); + } + + return lodash.groupBy( + deduplicateRelations(relations), + r => r.source_full_name, + ); } } diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 1307b063f2..5a9f60853a 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -33,6 +33,7 @@ export type DbEntitiesRow = { export type DbEntityRequest = { locationId?: string; entity: Entity; + relations: EntityRelationSpec[]; }; export type DbEntityResponse = { @@ -184,10 +185,12 @@ export type Database = { removeEntityByUid(tx: Transaction, uid: string): Promise; /** - * Remove current relations for the entity and replace them with the new relations array + * Remove current relations for the entity and replace them with the new + * relations array. + * * @param tx An ongoing transaction - * @param entityUid the entity uid - * @param relations the relationships to be set + * @param entityUid The entity uid + * @param relations The relationships to be set */ setRelations( tx: Transaction, @@ -208,7 +211,7 @@ export type Database = { addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise; }; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 40b48ca4ab..b9078df93f 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -372,10 +372,9 @@ describe('HigherOrderOperations', () => { '123', undefined, ); - expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith( - '123', + expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith('123', [ 'c1', - ); + ]); }); it('logs unsuccessful updates when reader fails', async () => { diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index bc277e1767..467c0eb2e8 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation { // Write if (!previousLocation && !dryRun) { // TODO: We do not include location operations in the dryRun. We might perform - // this operation as a seperate dry run. + // this operation as a separate dry run. await this.locationsCatalog.addLocation(location); } if (readerOutput.entities.length === 0) { @@ -116,34 +116,44 @@ export class HigherOrderOperations implements HigherOrderOperation { */ async refreshAllLocations(): Promise { const startTimestamp = process.hrtime(); - this.logger.info('Beginning locations refresh'); + const logger = this.logger.child({ + component: 'catalog-all-locations-refresh', + }); + + logger.info('Locations Refresh: Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - this.logger.info(`Visiting ${locations.length} locations`); + logger.info(`Locations Refresh: Visiting ${locations.length} locations`); for (const { data: location } of locations) { - this.logger.info( - `Refreshing location ${location.type}:${location.target}`, + logger.info( + `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) { - this.logger.warn( - `Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, + logger.warn( + `Locations Refresh: Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, ); await this.locationsCatalog.logUpdateFailure(location.id, e); } } - this.logger.info( - `Completed locations refresh in ${durationText(startTimestamp)}`, + logger.info( + `Locations Refresh: Completed locations refresh in ${durationText( + startTimestamp, + )}`, ); } // 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, @@ -151,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)}`, @@ -180,16 +190,14 @@ export class HigherOrderOperations implements HigherOrderOperation { throw e; } - this.logger.info(`Posting update success markers`); + logger.debug(`Posting update success markers`); - for (const entity of readerOutput.entities) { - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.entity.metadata.name, - ); - } + 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 new file mode 100644 index 0000000000..292564dc4f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; +import * as winston from 'winston'; + +describe('AwsOrganizationCloudAccountProcessor', () => { + describe('readLocation', () => { + const processor = new AwsOrganizationCloudAccountProcessor({ + provider: {}, + logger: winston.createLogger(), + }); + const location = { type: 'aws-cloud-accounts', target: '' }; + const emit = jest.fn(); + const listAccounts = jest.fn(); + + processor.organizations.listAccounts = listAccounts; + 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, + }; + }, + }; + }); + await processor.readLocation(location, false, emit); + expect(emit).toBeCalledWith({ + type: 'entity', + location, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + 'amazonaws.com/arn': + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + 'amazonaws.com/account-id': '957140518395', + 'amazonaws.com/organization-id': 'o-1vl18kc5a3', + }, + name: 'testaccount', + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }, + }); + }); + + it('filters out accounts not in specified location target', async () => { + const locationTest = { + 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(locationTest, false, emit); + expect(emit).toBeCalledTimes(1); + expect(emit).toBeCalledWith({ + type: 'entity', + location: locationTest, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + 'amazonaws.com/arn': + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + 'amazonaws.com/account-id': '957140518395', + 'amazonaws.com/organization-id': 'o-1vl18kc5a3', + }, + name: 'testaccount', + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts new file mode 100644 index 0000000000..7964541840 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; +import AWS, { Credentials, Organizations } from 'aws-sdk'; +import { Account, ListAccountsResponse } from 'aws-sdk/clients/organizations'; + +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; +import { + AwsOrganizationProviderConfig, + readAwsOrganizationConfig, +} from './awsOrganization/config'; + +const AWS_ORGANIZATION_REGION = 'us-east-1'; +const LOCATION_TYPE = 'aws-cloud-accounts'; + +const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id'; +const ARN_ANNOTATION: string = 'amazonaws.com/arn'; +const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; + +/** + * A processor for ingesting AWS Accounts from AWS Organizations. + * + * If custom authentication is needed, it can be achieved by configuring the global AWS.credentials object. + */ +export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { + logger: Logger; + organizations: Organizations; + provider: AwsOrganizationProviderConfig; + + static fromConfig(config: Config, options: { logger: Logger }) { + const c = config.getOptionalConfig('catalog.processors.awsOrganization'); + return new AwsOrganizationCloudAccountProcessor({ + ...options, + provider: c ? readAwsOrganizationConfig(c) : {}, + }); + } + + constructor(options: { + provider: AwsOrganizationProviderConfig; + logger: Logger; + }) { + this.provider = options.provider; + this.logger = options.logger; + let credentials = undefined; + if ( + this.provider.roleArn !== undefined && + AWS.config.credentials instanceof Credentials + ) { + credentials = new AWS.ChainableTemporaryCredentials({ + masterCredentials: AWS.config.credentials as Credentials, + params: { + RoleSessionName: 'backstage-aws-organization-processor', + RoleArn: this.provider.roleArn, + }, + }); + } + this.organizations = new AWS.Organizations({ + credentials, + region: AWS_ORGANIZATION_REGION, + }); // Only available in us-east-1 + } + + normalizeName(name: string): string { + return name + .trim() + .toLocaleLowerCase() + .replace(/[^a-zA-Z0-9\-]/g, '-'); + } + + extractInformationFromArn( + arn: string, + ): { accountId: string; organizationId: string } { + const parts = arn.split('/'); + + return { + accountId: parts[parts.length - 1], + organizationId: parts[parts.length - 2], + }; + } + + async getAwsAccounts(): Promise { + let awsAccounts: Account[] = []; + let isInitialAttempt = true; + let nextToken = undefined; + while (isInitialAttempt || nextToken) { + isInitialAttempt = false; + const orgAccounts: ListAccountsResponse = await this.organizations + .listAccounts({ NextToken: nextToken }) + .promise(); + if (orgAccounts.Accounts) { + awsAccounts = awsAccounts.concat(orgAccounts.Accounts); + } + nextToken = orgAccounts.NextToken; + } + + return awsAccounts; + } + + mapAccountToComponent(account: Account): ResourceEntityV1alpha1 { + const { accountId, organizationId } = this.extractInformationFromArn( + account.Arn as string, + ); + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + [ACCOUNTID_ANNOTATION]: accountId, + [ARN_ANNOTATION]: account.Arn || '', + [ORGANIZATION_ANNOTATION]: organizationId, + }, + name: this.normalizeName(account.Name || ''), + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== LOCATION_TYPE) { + return false; + } + + (await this.getAwsAccounts()) + .map(account => this.mapAccountToComponent(account)) + .filter(entity => { + if (location.target !== '') { + if (entity.metadata.annotations) { + return ( + entity.metadata.annotations[ORGANIZATION_ANNOTATION] === + location.target + ); + } + return false; + } + return true; + }) + .forEach((entity: ResourceEntityV1alpha1) => { + emit(results.entity(location, entity)); + }); + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index e456dfd7a2..feb4791477 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -17,40 +17,15 @@ import { ApiEntity, ComponentEntity, + DomainEntity, GroupEntity, + ResourceEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; describe('BuiltinKindsEntityProcessor', () => { - it('fills in fields for #3049', async () => { - const p = new BuiltinKindsEntityProcessor(); - const result = await p.preProcessEntity({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'n', - }, - spec: { - type: 't', - children: [], - } as any, - }); - expect(result).toEqual({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'n', - }, - spec: { - type: 't', - children: [], - ancestors: [], - descendants: [], - }, - }); - }); - describe('postProcessEntity', () => { const processor = new BuiltinKindsEntityProcessor(); const location = { type: 'a', target: 'b' }; @@ -66,16 +41,17 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 'service', owner: 'o', + subcomponentOf: 's', lifecycle: 'l', - implementsApis: ['a'], providesApis: ['b'], consumesApis: ['c'], + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(8); + expect(emit).toBeCalledTimes(10); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -92,22 +68,6 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); - expect(emit).toBeCalledWith({ - type: 'relation', - relation: { - source: { kind: 'API', namespace: 'default', name: 'a' }, - type: 'apiProvidedBy', - target: { kind: 'Component', namespace: 'default', name: 'n' }, - }, - }); - expect(emit).toBeCalledWith({ - type: 'relation', - relation: { - source: { kind: 'Component', namespace: 'default', name: 'n' }, - type: 'providesApi', - target: { kind: 'API', namespace: 'default', name: 'a' }, - }, - }); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -140,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 () => { @@ -152,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: { @@ -174,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 () => { @@ -215,9 +352,7 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 't', parent: 'p', - ancestors: [], children: ['c'], - descendants: [], }, }; diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 62b496dd65..26a8538707 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,12 +33,19 @@ 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, + templateEntityV1beta2Validator, UserEntity, userEntityV1alpha1Validator, } from '@backstage/catalog-model'; @@ -47,35 +56,20 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ apiEntityV1alpha1Validator, componentEntityV1alpha1Validator, + resourceEntityV1alpha1Validator, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, templateEntityV1alpha1Validator, + templateEntityV1beta2Validator, userEntityV1alpha1Validator, + systemEntityV1alpha1Validator, + domainEntityV1alpha1Validator, ]; - async preProcessEntity(entity: Entity): Promise { - // NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields - // scheduled for removal Dec 6th, 2020. This code can be deleted after that - // point. See https://github.com/backstage/backstage/issues/3049 - if ( - entity.apiVersion === 'backstage.io/v1alpha1' && - entity.kind === 'Group' && - entity.spec - ) { - if (!entity.spec.ancestors) { - entity.spec.ancestors = []; - } - if (!entity.spec.descendants) { - entity.spec.descendants = []; - } - } - return entity; - } - 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; } } @@ -135,10 +129,10 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNER_OF, ); doEmit( - component.spec.implementsApis, - { defaultKind: 'API', defaultNamespace: selfRef.namespace }, - RELATION_PROVIDES_API, - RELATION_API_PROVIDED_BY, + component.spec.subcomponentOf, + { defaultKind: 'Component', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, ); doEmit( component.spec.providesApis, @@ -152,6 +146,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, + ); } /* @@ -166,6 +166,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, + ); } /* @@ -202,6 +228,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 068a1e6e9a..eb1187eda5 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { CodeOwnersEntry } from 'codeowners-utils'; -import { createLogger } from 'winston'; import { buildCodeOwnerUrl, buildUrl, @@ -28,7 +28,7 @@ import { resolveCodeOwner, } from './CodeOwnersProcessor'; -const logger = createLogger(); +const logger = getVoidLogger(); describe('CodeOwnersProcessor', () => { const mockUrl = ({ basePath = '' } = {}): string => @@ -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/FileReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts new file mode 100644 index 0000000000..0155f93916 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.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 { FileReaderProcessor } from './FileReaderProcessor'; +import { + CatalogProcessorEntityResult, + CatalogProcessorErrorResult, + CatalogProcessorResult, +} from './types'; +import path from 'path'; + +describe('FileReaderProcessor', () => { + const fixturesRoot = path.join( + 'src', + 'ingestion', + 'processors', + '__fixtures__', + 'fileReaderProcessor', + ); + + it('should load from file', async () => { + const processor = new FileReaderProcessor(); + const spec = { + type: 'file', + target: `${path.join(fixturesRoot, 'component.yaml')}`, + }; + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as CatalogProcessorEntityResult; + + expect(generated.type).toBe('entity'); + expect(generated.location).toEqual(spec); + expect(generated.entity).toEqual({ kind: 'Component' }); + }); + + it('should fail load from file with error', async () => { + const processor = new FileReaderProcessor(); + const spec = { + type: 'file', + target: `${path.join(fixturesRoot, 'missing.yaml')}`, + }; + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as CatalogProcessorErrorResult; + + expect(generated.type).toBe('error'); + expect(generated.location).toBe(spec); + expect(generated.error.name).toBe('NotFoundError'); + expect(generated.error.message).toBe( + `file ${path.join(fixturesRoot, 'missing.yaml')} does not exist`, + ); + }); + + it('should support globs', async () => { + const processor = new FileReaderProcessor(); + + const emit = jest.fn(); + + await processor.readLocation( + { type: 'file', target: `${path.join(fixturesRoot, '**', '*.yaml')}` }, + false, + emit, + ); + + expect(emit).toBeCalledTimes(2); + expect(emit.mock.calls[0][0].entity).toEqual({ kind: 'Component' }); + expect(emit.mock.calls[1][0].entity).toEqual({ kind: 'API' }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts index 2328ec3c7a..db069913a2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts @@ -19,6 +19,10 @@ import fs from 'fs-extra'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; import { parseEntityYaml } from './util/parse'; +import { promisify } from 'util'; +import g from 'glob'; + +const glob = promisify(g); export class FileReaderProcessor implements CatalogProcessor { async readLocation( @@ -31,12 +35,15 @@ export class FileReaderProcessor implements CatalogProcessor { } try { - const exists = await fs.pathExists(location.target); - if (exists) { - const data = await fs.readFile(location.target); + const fileMatches = await glob(location.target); - for (const parseResult of parseEntityYaml(data, location)) { - emit(parseResult); + if (fileMatches.length > 0) { + for (const fileMatch of fileMatches) { + const data = await fs.readFile(fileMatch); + + for (const parseResult of parseEntityYaml(data, location)) { + emit(parseResult); + } } } else if (!optional) { const message = `${location.type} ${location.target} does not exist`; diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts new file mode 100644 index 0000000000..9c9aa41740 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { LocationSpec } from '@backstage/catalog-model'; +import { GithubDiscoveryProcessor, parseUrl } from './GithubDiscoveryProcessor'; +import { getOrganizationRepositories } from './github'; +import { ConfigReader } from '@backstage/config'; + +jest.mock('./github'); +const mockGetOrganizationRepositories = getOrganizationRepositories as jest.MockedFunction< + typeof getOrganizationRepositories +>; + +describe('GithubDiscoveryProcessor', () => { + describe('parseUrl', () => { + it('parses well formed URLs', () => { + expect( + parseUrl('https://github.com/foo/proj/blob/master/catalog.yaml'), + ).toEqual({ + org: 'foo', + repoSearchPath: /^proj$/, + catalogPath: '/blob/master/catalog.yaml', + }); + expect( + parseUrl('https://github.com/foo/proj*/blob/master/catalog.yaml'), + ).toEqual({ + org: 'foo', + repoSearchPath: /^proj.*$/, + catalogPath: '/blob/master/catalog.yaml', + }); + }); + + it('throws on incorrectly formed URLs', () => { + expect(() => parseUrl('https://github.com')).toThrow(); + expect(() => parseUrl('https://github.com//')).toThrow(); + expect(() => parseUrl('https://github.com/foo')).toThrow(); + expect(() => parseUrl('https://github.com//foo')).toThrow(); + expect(() => parseUrl('https://github.com/org/teams')).toThrow(); + expect(() => parseUrl('https://github.com/org//teams')).toThrow(); + }); + }); + + describe('reject unrelated entries', () => { + it('rejects unknown types', async () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'not-github-discovery', + target: 'https://github.com', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).resolves.toBeFalsy(); + }); + + it('rejects unknown targets', async () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'blob' }, + { host: 'ghe.example.net', token: 'blob' }, + ], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://not.github.com/apa', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no GitHub integration that matches https:\/\/not.github.com\/apa/, + ); + }); + }); + + describe('handles repositories', () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + + beforeEach(() => { + mockGetOrganizationRepositories.mockClear(); + }); + + it('output all repositories', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://github.com/backstage/*/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { name: 'demo', url: 'https://github.com/backstage/demo' }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/catalog.yaml', + }, + optional: true, + }); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: 'https://github.com/backstage/demo/blob/master/catalog.yaml', + }, + optional: true, + }); + }); + + it('output repositories with wildcards', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: + 'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { + name: 'techdocs-cli', + url: 'https://github.com/backstage/techdocs-cli', + }, + { + name: 'techdocs-container', + url: 'https://github.com/backstage/techdocs-container', + }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/techdocs-cli/blob/master/catalog.yaml', + }, + optional: true, + }); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/techdocs-container/blob/master/catalog.yaml', + }, + optional: true, + }); + }); + it('filter unrelated repositories', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://github.com/backstage/test/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'abstest', url: 'https://github.com/backstage/abctest' }, + { + name: 'test', + url: 'https://github.com/backstage/test', + }, + { + name: 'testxyz', + url: 'https://github.com/backstage/testxyz', + }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: 'https://github.com/backstage/test/blob/master/catalog.yaml', + }, + optional: true, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts new file mode 100644 index 0000000000..34f7fa2782 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts @@ -0,0 +1,133 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + GithubCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; +import { graphql } from '@octokit/graphql'; +import { Logger } from 'winston'; +import { getOrganizationRepositories } from './github'; +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; + +/** + * Extracts repositories out of a GitHub org. + */ +export class GithubDiscoveryProcessor implements CatalogProcessor { + private readonly integrations: ScmIntegrations; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const integrations = ScmIntegrations.fromConfig(config); + + return new GithubDiscoveryProcessor({ + ...options, + integrations, + }); + } + + constructor(options: { integrations: ScmIntegrations; logger: Logger }) { + this.integrations = options.integrations; + this.logger = options.logger; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== 'github-discovery') { + return false; + } + + const gitHubConfig = this.integrations.github.byUrl(location.target) + ?.config; + if (!gitHubConfig) { + throw new Error( + `There is no GitHub integration that matches ${location.target}. Please add a configuration entry for it under integrations.github`, + ); + } + const { headers } = await GithubCredentialsProvider.create( + gitHubConfig, + ).getCredentials({ url: location.target }); + const { org, repoSearchPath, catalogPath } = parseUrl(location.target); + + const client = graphql.defaults({ + baseUrl: gitHubConfig.apiBaseUrl, + headers, + }); + + // Read out all of the raw data + const startTimestamp = Date.now(); + this.logger.info(`Reading GitHub repositories from ${location.target}`); + + const { repositories } = await getOrganizationRepositories(client, org); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${repositories.length} GitHub repositories in ${duration} seconds`, + ); + + for (const repository of repositories) { + if (!repoSearchPath.test(repository.name)) { + continue; + } + emit( + results.location( + { + type: 'url', + target: `${repository.url}${catalogPath}`, + }, + // Not all locations may actually exist, since the user defined them as a wildcard pattern. + // Thus, we emit them as optional and let the downstream processor find them while not outputting + // an error if it couldn't. + true, + ), + ); + } + + return true; + } +} + +/* + * Helpers + */ + +export function parseUrl( + urlString: string, +): { org: string; repoSearchPath: RegExp; catalogPath: string } { + const url = new URL(urlString); + const path = url.pathname.substr(1).split('/'); + + // /backstage/techdocs-*/blob/master/catalog-info.yaml + if (path.length > 2 && path[0].length && path[1].length) { + return { + org: decodeURIComponent(path[0]), + repoSearchPath: escapeRegExp(decodeURIComponent(path[1])), + catalogPath: `/${decodeURIComponent(path.slice(2).join('/'))}`, + }; + } + + throw new Error(`Failed to parse ${urlString}`); +} + +export function escapeRegExp(str: string): RegExp { + return new RegExp(`^${str.replace(/\*/g, '.*')}$`); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts index fdba32ef7e..19a08d219f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -16,74 +16,9 @@ import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; -import { - GithubOrgReaderProcessor, - parseUrl, - readConfig, -} from './GithubOrgReaderProcessor'; +import { GithubOrgReaderProcessor, parseUrl } from './GithubOrgReaderProcessor'; describe('GithubOrgReaderProcessor', () => { - describe('readConfig', () => { - function config( - providers: { target: string; apiBaseUrl?: string; token?: string }[], - ) { - return ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { processors: { githubOrg: { providers } } }, - }, - }, - ]); - } - - it('adds a default GitHub entry when missing', () => { - const output = readConfig(config([])); - expect(output).toEqual([ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ]); - }); - - it('injects the correct GitHub API base URL when missing', () => { - const output = readConfig(config([{ target: 'https://github.com' }])); - expect(output).toEqual([ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ]); - }); - - it('rejects custom targets with no base URLs', () => { - expect(() => - readConfig(config([{ target: 'https://ghe.company.com' }])), - ).toThrow( - 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', - ); - }); - - it('rejects funky configs', () => { - expect(() => readConfig(config([{ target: 7 } as any]))).toThrow( - /target/, - ); - expect(() => readConfig(config([{ noTarget: '7' } as any]))).toThrow( - /target/, - ); - expect(() => - readConfig( - config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), - ), - ).toThrow(/apiBaseUrl/); - expect(() => - readConfig(config([{ target: 'https://github.com', token: 7 } as any])), - ).toThrow(/token/); - }); - }); - describe('parseUrl', () => { it('only supports clean org urls, and decodes them', () => { expect(() => parseUrl('https://github.com')).toThrow(); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 9aecb8b86e..6a83d705f6 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -18,9 +18,14 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; +import { + getOrganizationTeams, + getOrganizationUsers, + ProviderConfig, + readGithubConfig, +} from './github'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { getOrganizationTeams, getOrganizationUsers } from './util/github'; import { buildOrgHierarchy } from './util/org'; /** @@ -33,7 +38,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { static fromConfig(config: Config, options: { logger: Logger }) { return new GithubOrgReaderProcessor({ ...options, - providers: readConfig(config), + providers: readGithubConfig(config), }); } @@ -64,6 +69,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { const client = !provider.token ? graphql : graphql.defaults({ + baseUrl: provider.apiBaseUrl, headers: { authorization: `token ${provider.token}`, }, @@ -112,73 +118,6 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { * Helpers */ -/** - * The configuration parameters for a single GitHub API provider. - */ -type ProviderConfig = { - /** - * The prefix of the target that this matches on, e.g. "https://github.com", - * with no trailing slash. - */ - target: string; - - /** - * The base URL of the API of this provider, e.g. "https://api.github.com", - * with no trailing slash. - * - * May be omitted specifically for GitHub; then it will be deduced. - */ - apiBaseUrl?: string; - - /** - * The authorization token to use for requests to this provider. - * - * If no token is specified, anonymous access is used. - */ - token?: string; -}; - -// TODO(freben): Break out common code and config from here and GithubReaderProcessor -export function readConfig(config: Config): ProviderConfig[] { - const providers: ProviderConfig[] = []; - - const providerConfigs = - config.getOptionalConfigArray('catalog.processors.githubOrg.providers') ?? - []; - - // First read all the explicit providers - for (const providerConfig of providerConfigs) { - const target = providerConfig.getString('target').replace(/\/+$/, ''); - let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); - const token = providerConfig.getOptionalString('token'); - - if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); - } else if (target === 'https://github.com') { - apiBaseUrl = 'https://api.github.com'; - } - - if (!apiBaseUrl) { - throw new Error( - `Provider at ${target} must configure an explicit apiBaseUrl`, - ); - } - - providers.push({ target, apiBaseUrl, token }); - } - - // If no explicit github.com provider was added, put one in the list as - // a convenience - if (!providers.some(p => p.target === 'https://github.com')) { - providers.push({ - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }); - } - - return providers; -} - export function parseUrl(urlString: string): { org: string } { const path = new URL(urlString).pathname.substr(1).split('/'); 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 5a3511af6d..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(); @@ -88,7 +88,7 @@ describe('PlaceholderProcessor', () => { ); }); - it('rejects multiple placeholders', async () => { + it('ignores multiple placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { foo: jest.fn(), @@ -96,41 +96,35 @@ describe('PlaceholderProcessor', () => { }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); - it('rejects unknown placeholders', async () => { + it('ignores unknown placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { bar: jest.fn(), }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow('Encountered unknown placeholder $foo'); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index 0e23d51f61..b7b3df7b6e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -76,21 +76,28 @@ export class PlaceholderProcessor implements CatalogProcessor { ? [data, false] : [Object.fromEntries(entries.map(([k, [v]]) => [k, v])), true]; } else if (keys.length !== 1) { - throw new Error( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + // This was an object that had more than one key, some of which were + // dollar prefixed. We only handle the case where there is exactly one + // such key; anything else is left alone. + return [data, false]; } const resolverKey = keys[0].substr(1); + const resolverValue = data[keys[0]]; const resolver = this.options.resolvers[resolverKey]; - if (!resolver) { - throw new Error(`Encountered unknown placeholder \$${resolverKey}`); + if (!resolver || typeof resolverValue !== 'string') { + // If there was no such placeholder resolver or if the value was not a + // string, we err on the side of safety and assume that this is + // something that's best left alone. For example, if the input contains + // JSONSchema, there may be "$ref": "#/definitions/node" nodes in the + // document. + return [data, false]; } return [ await resolver({ key: resolverKey, - value: data[keys[0]], + value: resolverValue, baseUrl: location.target, read: this.options.reader.read.bind(this.options.reader), }), 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 d31f053c80..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,22 +48,27 @@ export class UrlReaderProcessor implements CatalogProcessor { location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise { if (deprecatedTypes.includes(location.type)) { - // TODO(Rugvip): Let's not enable this warning yet, as we want to move over the example YAMLs - // in this repo to use the 'url' type first. - // this.options.logger.warn( - // `Using deprecated location type '${location.type}' for '${location.target}', use 'url' instead`, - // ); + // TODO(Rugvip): Remove this warning a month or two into 2021, and remove support for the deprecated types. + this.options.logger.warn( + `Location '${location.target}' uses deprecated location type '${location.type}', use 'url' instead. ` + + 'Use "scripts/migrate-location-types.js" in the Backstage repo to migrate existing locations.', + ); } else if (location.type !== 'url') { return false; } 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/__fixtures__/fileReaderProcessor/component.yaml b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/component.yaml new file mode 100644 index 0000000000..8524aaf14a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/component.yaml @@ -0,0 +1 @@ +kind: Component diff --git a/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/dir/api.yaml b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/dir/api.yaml new file mode 100644 index 0000000000..a894e34f70 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/dir/api.yaml @@ -0,0 +1 @@ +kind: API diff --git a/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/test.txt b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/test.txt new file mode 100644 index 0000000000..f332206601 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/__fixtures__/fileReaderProcessor/test.txt @@ -0,0 +1 @@ +Just a text file diff --git a/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts new file mode 100644 index 0000000000..09d95b479d --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { readAwsOrganizationConfig } from './config'; + +describe('readAwsOrganizationConfig', () => { + it('applies all of the defaults', () => { + const config = { + provider: { + roleArn: 'aws::arn::foo', + }, + }; + const actual = readAwsOrganizationConfig(new ConfigReader(config)); + const expected = { + roleArn: 'aws::arn::foo', + }; + expect(actual).toEqual(expected); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts new file mode 100644 index 0000000000..90206f504c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; + +/** + * The configuration parameters for a single AWS Organization Processor + */ +export type AwsOrganizationProviderConfig = { + /** + * The role to assume for the processor. + */ + roleArn?: string; +}; + +export function readAwsOrganizationConfig( + config: Config, +): AwsOrganizationProviderConfig { + const providerConfig = config.getOptionalConfig('provider'); + + const roleArn = providerConfig?.getOptionalString('roleArn'); + return { + roleArn, + }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts new file mode 100644 index 0000000000..14f3caa42c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { readGithubConfig } from './config'; + +describe('config', () => { + describe('readGithubConfig', () => { + function config( + providers: { target: string; apiBaseUrl?: string; token?: string }[], + ) { + return new ConfigReader({ + catalog: { processors: { githubOrg: { providers } } }, + }); + } + + it('adds a default GitHub entry when missing', () => { + const output = readGithubConfig(config([])); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('injects the correct GitHub API base URL when missing', () => { + const output = readGithubConfig( + config([{ target: 'https://github.com' }]), + ); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('rejects custom targets with no base URLs', () => { + expect(() => + readGithubConfig(config([{ target: 'https://ghe.company.com' }])), + ).toThrow( + 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', + ); + }); + + it('rejects funky configs', () => { + expect(() => readGithubConfig(config([{ target: 7 } as any]))).toThrow( + /target/, + ); + expect(() => + readGithubConfig(config([{ noTarget: '7' } as any])), + ).toThrow(/target/); + expect(() => + readGithubConfig( + config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readGithubConfig( + config([{ target: 'https://github.com', token: 7 } as any]), + ), + ).toThrow(/token/); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.ts new file mode 100644 index 0000000000..88f2f96218 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.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 { Config } from '@backstage/config'; + +/** + * The configuration parameters for a single GitHub API provider. + */ +export type ProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. "https://github.com", + * with no trailing slash. + */ + target: string; + + /** + * The base URL of the API of this provider, e.g. "https://api.github.com", + * with no trailing slash. + * + * May be omitted specifically for GitHub; then it will be deduced. + */ + apiBaseUrl?: string; + + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous access is used. + */ + token?: string; +}; + +// TODO(freben): Break out common code and config from here and GithubReaderProcessor +export function readGithubConfig(config: Config): ProviderConfig[] { + const providers: ProviderConfig[] = []; + + const providerConfigs = + config.getOptionalConfigArray('catalog.processors.githubOrg.providers') ?? + []; + + // First read all the explicit providers + for (const providerConfig of providerConfigs) { + const target = providerConfig.getString('target').replace(/\/+$/, ''); + let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); + const token = providerConfig.getOptionalString('token'); + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (target === 'https://github.com') { + apiBaseUrl = 'https://api.github.com'; + } + + if (!apiBaseUrl) { + throw new Error( + `Provider at ${target} must configure an explicit apiBaseUrl`, + ); + } + + providers.push({ target, apiBaseUrl, token }); + } + + // If no explicit github.com provider was added, put one in the list as + // a convenience + if (!providers.some(p => p.target === 'https://github.com')) { + providers.push({ + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }); + } + + return providers; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts similarity index 71% rename from plugins/catalog-backend/src/ingestion/processors/util/github.test.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 54afdd1bdc..81b44c706d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -14,14 +14,15 @@ * limitations under the License. */ +import { msw } from '@backstage/test-utils'; import { graphql } from '@octokit/graphql'; import { graphql as graphqlMsw } from 'msw'; import { setupServer } from 'msw/node'; -import { msw } from '@backstage/test-utils'; import { getOrganizationTeams, getOrganizationUsers, getTeamMembers, + getOrganizationRepositories, QueryResponse, } from './github'; @@ -78,6 +79,9 @@ describe('github', () => { { slug: 'team', combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', parentTeam: { slug: 'parent', combinedSlug: '', @@ -96,13 +100,18 @@ describe('github', () => { const output = { groups: [ expect.objectContaining({ - metadata: expect.objectContaining({ name: 'team' }), + metadata: expect.objectContaining({ + name: 'team', + description: 'The one and only team', + }), spec: { type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, parent: 'parent', - ancestors: [], children: [], - descendants: [], }, }), ], @@ -143,4 +152,48 @@ describe('github', () => { await expect(getTeamMembers(graphql, 'a', 'b')).resolves.toEqual(output); }); }); + + describe('getOrganizationRepositories', () => { + it('read repositories', async () => { + const input: QueryResponse = { + organization: { + repositories: { + nodes: [ + { + name: 'backstage', + url: 'https://github.com/backstage/backstage', + }, + { + name: 'demo', + url: 'https://github.com/backstage/demo', + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + }; + + const output = { + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { + name: 'demo', + url: 'https://github.com/backstage/demo', + }, + ], + }; + + server.use( + graphqlMsw.query('repositories', (_req, res, ctx) => + res(ctx.data(input)), + ), + ); + + await expect(getOrganizationRepositories(graphql, 'a')).resolves.toEqual( + output, + ); + }); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts similarity index 86% rename from plugins/catalog-backend/src/ingestion/processors/util/github.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.ts index 96cf4a7668..d50887c592 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -27,6 +27,7 @@ export type Organization = { membersWithRole?: Connection; team?: Team; teams?: Connection; + repositories?: Connection; }; export type PageInfo = { @@ -45,11 +46,18 @@ export type User = { export type Team = { slug: string; combinedSlug: string; + name?: string; description?: string; + avatarUrl?: string; parentTeam?: Team; members: Connection; }; +export type Repository = { + name: string; + url: string; +}; + export type Connection = { pageInfo: PageInfo; nodes: T[]; @@ -137,6 +145,9 @@ export async function getOrganizationTeams( nodes { slug combinedSlug + name + description + avatarUrl parentTeam { slug } members(first: 100, membership: IMMEDIATE) { pageInfo { hasNextPage } @@ -162,14 +173,23 @@ export async function getOrganizationTeams( }, spec: { type: 'team', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; - if (team.description) entity.metadata.description = team.description; - if (team.parentTeam) entity.spec.parent = team.parentTeam.slug; + if (team.description) { + entity.metadata.description = team.description; + } + if (team.name) { + entity.spec.profile!.displayName = team.name; + } + if (team.avatarUrl) { + entity.spec.profile!.picture = team.avatarUrl; + } + if (team.parentTeam) { + entity.spec.parent = team.parentTeam.slug; + } const memberNames: string[] = []; groupMemberUsers.set(team.slug, memberNames); @@ -202,6 +222,39 @@ export async function getOrganizationTeams( return { groups, groupMemberUsers }; } +export async function getOrganizationRepositories( + client: typeof graphql, + org: string, +): Promise<{ repositories: Repository[] }> { + const query = ` + query repositories($org: String!, $cursor: String) { + organization(login: $org) { + name + repositories(first: 100, after: $cursor) { + nodes { + name + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + + const repositories = await queryWithPaging( + client, + query, + r => r.organization?.repositories, + x => x, + { org }, + ); + + return { repositories }; +} + /** * Gets all the users out of a GitHub organization. * diff --git a/plugins/catalog-backend/src/ingestion/processors/github/index.ts b/plugins/catalog-backend/src/ingestion/processors/github/index.ts new file mode 100644 index 0000000000..2063e8c1b2 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { readGithubConfig } from './config'; +export type { ProviderConfig } from './config'; +export { + getOrganizationTeams, + getOrganizationUsers, + getOrganizationRepositories, +} from './github'; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 49d4e03b64..736c64ea39 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -16,20 +16,20 @@ import * as results from './results'; -export { results }; -export * from './types'; - -export { parseEntityYaml } from './util/parse'; - export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; +export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor'; 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'; export { StaticLocationProcessor } from './StaticLocationProcessor'; +export * from './types'; export { UrlReaderProcessor } from './UrlReaderProcessor'; +export { parseEntityYaml } from './util/parse'; +export { results }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 7e6036668f..5e293766aa 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -32,9 +32,7 @@ describe('readLdapConfig', () => { }, ], }; - const actual = readLdapConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readLdapConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -66,6 +64,7 @@ describe('readLdapConfig', () => { name: 'cn', description: 'description', type: 'groupType', + displayName: 'cn', memberOf: 'memberOf', members: 'member', }, @@ -114,6 +113,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, @@ -121,9 +123,7 @@ describe('readLdapConfig', () => { }, ], }; - const actual = readLdapConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readLdapConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -161,6 +161,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index 2a63b20619..7608f247d4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -109,6 +109,15 @@ export type GroupConfig = { // The name of the attribute that shall be used for the value of the // spec.type field of the entity. Defaults to "groupType". type: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.displayName field of the entity. Defaults to "cn". + displayName: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.email field of the entity. + email?: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.picture field of the entity. + picture?: string; // The name of the attribute that shall be used for the values of the // spec.parent field of the entity. Defaults to "memberOf". memberOf: string; @@ -141,6 +150,7 @@ const defaultConfig = { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', type: 'groupType', memberOf: 'memberOf', members: 'member', @@ -220,6 +230,9 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { name: c.getOptionalString('name'), description: c.getOptionalString('description'), type: c.getOptionalString('type'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), memberOf: c.getOptionalString('memberOf'), members: c.getOptionalString('members'), }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts index e644bab22a..4155c2124e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts @@ -47,7 +47,7 @@ function group(data: RecursivePartial): GroupEntity { apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: 'name' }, - spec: { type: 'type', ancestors: [], children: [], descendants: [] }, + spec: { type: 'type', children: [] }, } as GroupEntity, data, ); @@ -138,6 +138,8 @@ describe('readLdapGroups', () => { cn: ['cn-value'], description: ['description-value'], tt: ['type-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], memberOf: ['x', 'y', 'z'], member: ['e', 'f', 'g'], entryDN: ['dn-value'], @@ -151,6 +153,9 @@ describe('readLdapGroups', () => { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', type: 'tt', memberOf: 'memberOf', members: 'member', @@ -173,9 +178,12 @@ describe('readLdapGroups', () => { }, spec: { type: 'type-value', - ancestors: [], + profile: { + displayName: 'cn-value', + email: 'mail-value', + picture: 'avatarUrl-value', + }, children: [], - descendants: [], }, }), ]); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts index 63fce00bd8..f0fb19ac02 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -150,9 +150,8 @@ export async function readLdapGroups( }, spec: { type: 'unknown', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; @@ -180,6 +179,15 @@ export async function readLdapGroups( mapStringAttr(attributes, map.type, v => { entity.spec.type = v; }); + mapStringAttr(attributes, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(attributes, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(attributes, map.picture, v => { + entity.spec.profile!.picture = v; + }); mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts index e51a7753af..5ef82432bb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts @@ -210,7 +210,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toBeFalsy(); }); - it('should load profile photo', async () => { + it('should load user profile photo', async () => { worker.use( rest.get('https://example.com/users/user-id/photo/*', (_, res, ctx) => res(ctx.status(200), ctx.text('911')), @@ -222,7 +222,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toEqual('data:image/jpeg;base64,OTEx'); }); - it('should load profile photo for size 120', async () => { + it('should load user profile photo for size 120', async () => { worker.use( rest.get( 'https://example.com/users/user-id/photos/120/*', @@ -252,6 +252,46 @@ describe('MicrosoftGraphClient', () => { expect(values).toEqual([{ surname: 'Example' }]); }); + it('should load group profile photo with max size of 120', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photos', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { + height: 120, + id: 120, + }, + ], + }), + ), + ), + ); + worker.use( + rest.get( + 'https://example.com/groups/group-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhotoWithSizeLimit('group-id', 120); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load group profile photo', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photo/*', (_, res, ctx) => + res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhoto('group-id'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + it('should load groups', async () => { worker.use( rest.get('https://example.com/groups', (_, res, ctx) => diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts index 6dded56aa1..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}`, @@ -117,12 +121,65 @@ export class MicrosoftGraphClient { userId: string, maxSize: number, ): Promise { - const response = await this.requestApi(`users/${userId}/photos`); + return await this.getPhotoWithSizeLimit('users', userId, maxSize); + } + + async getUserPhoto( + userId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('users', userId, sizeId); + } + + async *getUsers(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`users`, query); + } + + async getGroupPhotoWithSizeLimit( + groupId: string, + maxSize: number, + ): Promise { + return await this.getPhotoWithSizeLimit('groups', groupId, maxSize); + } + + async getGroupPhoto( + groupId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('groups', groupId, sizeId); + } + + async *getGroups(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`groups`, query); + } + + async *getGroupMembers(groupId: string): AsyncIterable { + yield* this.requestCollection(`groups/${groupId}/members`); + } + + async getOrganization( + tenantId: string, + ): Promise { + const response = await this.requestApi(`organization/${tenantId}`); + + if (response.status !== 200) { + await this.handleError(`organization/${tenantId}`, response); + } + + return await response.json(); + } + + private async getPhotoWithSizeLimit( + entityName: string, + id: string, + maxSize: number, + ): Promise { + const response = await this.requestApi(`${entityName}/${id}/photos`); if (response.status === 404) { return undefined; } else if (response.status !== 200) { - await this.handleError('user photos', response); + await this.handleError(`${entityName} photos`, response); } const result = await response.json(); @@ -143,16 +200,17 @@ export class MicrosoftGraphClient { return undefined; } - return await this.getUserPhoto(userId, selectedPhoto.id!); + return await this.getPhoto(entityName, id, selectedPhoto.id!); } - async getUserPhoto( - userId: string, + private async getPhoto( + entityName: string, + id: string, sizeId?: string, ): Promise { const path = sizeId - ? `users/${userId}/photos/${sizeId}/$value` - : `users/${userId}/photo/$value`; + ? `${entityName}/${id}/photos/${sizeId}/$value` + : `${entityName}/${id}/photo/$value`; const response = await this.requestApi(path); if (response.status === 404) { @@ -166,30 +224,6 @@ export class MicrosoftGraphClient { ).toString('base64')}`; } - async *getUsers(query?: ODataQuery): AsyncIterable { - yield* this.requestCollection(`users`, query); - } - - async *getGroups(query?: ODataQuery): AsyncIterable { - yield* this.requestCollection(`groups`, query); - } - - async *getGroupMembers(groupId: string): AsyncIterable { - yield* this.requestCollection(`groups/${groupId}/members`); - } - - async getOrganization( - tenantId: string, - ): Promise { - const response = await this.requestApi(`organization/${tenantId}`); - - if (response.status !== 200) { - await this.handleError('organization/${tenantId}', response); - } - - return await response.json(); - } - private async handleError(path: string, response: Response): Promise { const result = await response.json(); const error = result.error as MicrosoftGraph.PublicError; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts index 11c63828d4..4671fd23ae 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts @@ -29,9 +29,7 @@ describe('readMicrosoftGraphConfig', () => { }, ], }; - const actual = readMicrosoftGraphConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readMicrosoftGraphConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -60,9 +58,7 @@ describe('readMicrosoftGraphConfig', () => { }, ], }; - const actual = readMicrosoftGraphConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readMicrosoftGraphConfig(new ConfigReader(config)); const expected = [ { target: 'target', diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts index c4d09e1372..72416a63ee 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts @@ -16,13 +16,46 @@ import { Config } from '@backstage/config'; +/** + * The configuration parameters for a single Microsoft Graph provider. + */ export type MicrosoftGraphProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. + * "https://graph.microsoft.com/v1.0", with no trailing slash. + */ target: string; - authority: string; + /** + * The auth authority used. + * + * E.g. "https://login.microsoftonline.com" + */ + authority?: string; + /** + * The tenant whose org data we are interested in. + */ tenantId: string; + /** + * The OAuth client ID to use for authenticating requests. + */ clientId: string; + /** + * The OAuth client secret to use for authenticating requests. + * + * @visibility secret + */ clientSecret: string; + /** + * The filter to apply to extract users. + * + * E.g. "accountEnabled eq true and userType eq 'member'" + */ userFilter?: string; + /** + * The filter to apply to extract groups. + * + * E.g. "securityEnabled eq false and mailEnabled eq true" + */ groupFilter?: string; }; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts index 3c2f33e922..07d540c848 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts @@ -49,9 +49,7 @@ function group(data: RecursivePartial): GroupEntity { name: 'name', }, spec: { - ancestors: [], children: [], - descendants: [], type: 'team', }, } as GroupEntity, @@ -65,6 +63,7 @@ describe('read microsoft graph', () => { getGroups: jest.fn(), getGroupMembers: jest.fn(), getUserPhotoWithSizeLimit: jest.fn(), + getGroupPhotoWithSizeLimit: jest.fn(), getOrganization: jest.fn(), } as any; @@ -152,6 +151,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }), ); @@ -167,6 +169,8 @@ describe('read microsoft graph', () => { yield { id: 'groupid', displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', }; } @@ -187,6 +191,9 @@ describe('read microsoft graph', () => { id: 'tenantid', displayName: 'Organization Name', }); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); const { groups, @@ -207,6 +214,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }); expect(groups).toEqual([ @@ -217,10 +227,17 @@ describe('read microsoft graph', () => { 'graph.microsoft.com/group-id': 'groupid', }, name: 'group_name', - description: 'Group Name', + description: 'Group Description', }, spec: { type: 'team', + profile: { + displayName: 'Group Name', + email: 'group@example.com', + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, }, }), ]); @@ -232,10 +249,14 @@ describe('read microsoft graph', () => { expect(client.getGroups).toBeCalledTimes(1); expect(client.getGroups).toBeCalledWith({ filter: 'securityEnabled eq false', - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], }); expect(client.getGroupMembers).toBeCalledTimes(1); expect(client.getGroupMembers).toBeCalledWith('groupid'); + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1); + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120); }); }); @@ -306,33 +327,20 @@ describe('read microsoft graph', () => { resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf); expect(rootGroup.spec.parent).toBeUndefined(); - expect(rootGroup.spec.ancestors).toEqual(expect.arrayContaining([])); expect(rootGroup.spec.children).toEqual( expect.arrayContaining(['a', 'b']), ); - expect(rootGroup.spec.descendants).toEqual( - expect.arrayContaining(['a', 'b', 'c']), - ); expect(groupA.spec.parent).toEqual('root'); - expect(groupA.spec.ancestors).toEqual(expect.arrayContaining(['root'])); expect(groupA.spec.children).toEqual(expect.arrayContaining([])); - expect(groupA.spec.descendants).toEqual(expect.arrayContaining([])); expect(groupB.spec.parent).toEqual('root'); - expect(groupB.spec.ancestors).toEqual(expect.arrayContaining(['root'])); expect(groupB.spec.children).toEqual(expect.arrayContaining(['c'])); - expect(groupB.spec.descendants).toEqual(expect.arrayContaining(['c'])); expect(groupC.spec.parent).toEqual('b'); - expect(groupC.spec.ancestors).toEqual( - expect.arrayContaining(['root', 'b']), - ); expect(groupC.spec.children).toEqual(expect.arrayContaining([])); - expect(groupC.spec.descendants).toEqual(expect.arrayContaining([])); expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a'])); - expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c'])); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts index 6cde2649c4..4409422645 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import limiterFactory from 'p-limit'; import { buildMemberOf, buildOrgHierarchy } from '../util/org'; import { MicrosoftGraphClient } from './client'; import { @@ -21,7 +22,6 @@ import { MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, MICROSOFT_GRAPH_USER_ID_ANNOTATION, } from './constants'; -import limiterFactory from 'p-limit'; export function normalizeEntityName(name: string): string { return name @@ -37,7 +37,7 @@ export async function readMicrosoftGraphUsers( users: UserEntity[]; // With all relations empty }> { const entities: UserEntity[] = []; - const picturePromises: Promise[] = []; + const promises: Promise[] = []; const limiter = limiterFactory(10); for await (const user of client.getUsers({ @@ -82,12 +82,12 @@ export async function readMicrosoftGraphUsers( ); }); - picturePromises.push(loadPhoto); + promises.push(loadPhoto); entities.push(entity); } // Wait for all photos to be downloaded - await Promise.all(picturePromises); + await Promise.all(promises); return { users: entities }; } @@ -98,7 +98,7 @@ export async function readMicrosoftGraphOrganization( ): Promise<{ rootGroup: GroupEntity; // With all relations empty }> { - // For now we expect a single root orgranization + // For now we expect a single root organization const organization = await client.getOrganization(tenantId); const name = normalizeEntityName(organization.displayName!); const rootGroup: GroupEntity = { @@ -113,9 +113,10 @@ export async function readMicrosoftGraphOrganization( }, spec: { type: 'root', - ancestors: [], + profile: { + displayName: organization.displayName!, + }, children: [], - descendants: [], }, }; @@ -141,11 +142,11 @@ export async function readMicrosoftGraphGroups( groupMember.set(rootGroup.metadata.name, new Set()); groups.push(rootGroup); - const groupMemberPromises: Promise[] = []; + const promises: Promise[] = []; for await (const group of client.getGroups({ filter: options?.groupFilter, - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], })) { if (!group.id || !group.displayName) { continue; @@ -157,20 +158,27 @@ export async function readMicrosoftGraphGroups( kind: 'Group', metadata: { name: name, - description: group.displayName, annotations: { [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, }, }, spec: { type: 'team', - // TODO: We could include a group email and picture - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; + if (group.description) { + entity.metadata.description = group.description; + } + if (group.displayName) { + entity.spec.profile!.displayName = group.displayName; + } + if (group.mail) { + entity.spec.profile!.email = group.mail; + } + // Download the members in parallel, otherwise it can take quite some time const loadGroupMembers = limiter(async () => { for await (const member of client.getGroupMembers(group.id!)) { @@ -188,12 +196,25 @@ export async function readMicrosoftGraphGroups( } }); - groupMemberPromises.push(loadGroupMembers); + // TODO: Loading groups doesn't work right now as Microsoft Graph doesn't + // allows this yet: https://microsoftgraph.uservoice.com/forums/920506-microsoft-graph-feature-requests/suggestions/37884922-allow-application-to-set-or-update-a-group-s-photo + /*/ / Download the photos in parallel, otherwise it can take quite some time + const loadPhoto = limiter(async () => { + entity.spec.profile!.picture = await client.getGroupPhotoWithSizeLimit( + group.id!, + // We are limiting the photo size, as groups with full resolution photos + // can make the Backstage API slow + 120, + ); + }); + + promises.push(loadPhoto);*/ + promises.push(loadGroupMembers); groups.push(entity); } - // Wait for all group members to be loaded - await Promise.all(groupMemberPromises); + // Wait for all group members and photos to be loaded + await Promise.all(promises); return { groups, @@ -278,7 +299,7 @@ export function resolveRelations( }); }); - // Make sure that all groups have proper ancestors and descendants + // Make sure that all groups have proper parents and children buildOrgHierarchy(groups); // Set relations for all users 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/org.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts index 7e5a2e9754..c9056eebb4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts @@ -26,7 +26,7 @@ function g( apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name }, - spec: { type: 'team', parent, children, ancestors: [], descendants: [] }, + spec: { type: 'team', parent, children }, }; } @@ -43,28 +43,16 @@ describe('buildOrgHierarchy', () => { expect(d.spec.children).toEqual([]); }); - it('fills out descendants', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); + it('sets parent of groups children', () => { + const a = g('a', undefined, ['b', 'd']); + const b = g('b', undefined, ['c']); + const c = g('c', undefined, []); + const d = g('d', undefined, []); buildOrgHierarchy([a, b, c, d]); - expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd'])); - expect(b.spec.descendants).toEqual(expect.arrayContaining(['c'])); - expect(c.spec.descendants).toEqual([]); - expect(d.spec.descendants).toEqual([]); - }); - - it('fills out ancestors', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); - buildOrgHierarchy([a, b, c, d]); - expect(a.spec.ancestors).toEqual([]); - expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a'])); - expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b'])); - expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a'])); + expect(a.spec.parent).toBeUndefined(); + expect(b.spec.parent).toBe('a'); + expect(c.spec.parent).toBe('b'); + expect(d.spec.parent).toBe('a'); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.ts index b033fe99d7..787e408e60 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.ts @@ -35,62 +35,17 @@ export function buildOrgHierarchy(groups: GroupEntity[]) { } // - // Make sure that g.descendants is complete + // Make sure that g.children.parent is g // - function visitDescendants(current: GroupEntity): string[] { - if (current.spec.descendants.length) { - return current.spec.descendants; - } - - const accumulator = new Set(); - for (const childName of current.spec.children) { - accumulator.add(childName); + for (const group of groups) { + const selfName = group.metadata.name; + for (const childName of group.spec.children) { const child = groupsByName.get(childName); - if (child) { - for (const d of visitDescendants(child)) { - accumulator.add(d); - } + if (child && !child.spec.parent) { + child.spec.parent = selfName; } } - - const descendants = Array.from(accumulator); - current.spec.descendants = descendants; - return descendants; - } - - for (const group of groups) { - visitDescendants(group); - } - - // - // Make sure that g.ancestors is complete - // - - function visitAncestors(current: GroupEntity): string[] { - if (current.spec.ancestors.length) { - return current.spec.ancestors; - } - - let ancestors: string[]; - const parentName = current.spec.parent; - if (!parentName) { - ancestors = []; - } else { - const parent = groupsByName.get(parentName); - if (parent) { - ancestors = [parentName, ...visitAncestors(parent)]; - } else { - ancestors = [parentName]; - } - } - - current.spec.ancestors = ancestors; - return ancestors; - } - - for (const group of groups) { - visitAncestors(group); } } @@ -100,15 +55,24 @@ export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) { const groupsByName = new Map(groups.map(g => [g.metadata.name, g])); users.forEach(user => { - const transitiveMemberOf = new Set([...user.spec.memberOf]); + const transitiveMemberOf = new Set(); - user.spec.memberOf.forEach(groupName => { - const group = groupsByName.get(groupName); - - if (group) { - group.spec.ancestors.forEach(g => transitiveMemberOf.add(g)); + const todo = [...user.spec.memberOf]; + for (;;) { + const current = todo.pop(); + if (!current) { + break; } - }); + + if (!transitiveMemberOf.has(current)) { + transitiveMemberOf.add(current); + const group = groupsByName.get(current); + if (group?.spec.parent) { + todo.push(group.spec.parent); + } + } + } + user.spec.memberOf = [...transitiveMemberOf]; }); } 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 58897a10a7..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,11 +44,12 @@ describe('CatalogBuilder', () => { const reader: jest.Mocked = { read: jest.fn(), readTree: jest.fn(), + search: jest.fn(), }; const env: CatalogEnvironment = { logger: getVoidLogger(), database: { getClient: async () => db }, - config: ConfigReader.fromConfigs([]), + config: new ConfigReader({}), reader, }; @@ -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..a75c7da6e8 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,16 @@ import { AnnotateLocationEntityProcessor, BuiltinKindsEntityProcessor, CatalogProcessor, + CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, + GithubDiscoveryProcessor, GithubOrgReaderProcessor, HigherOrderOperation, HigherOrderOperations, LdapOrgReaderProcessor, + LocationEntityProcessor, LocationReaders, - LocationRefProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -60,6 +63,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 +100,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 +110,7 @@ export class CatalogBuilder { this.fieldFormatValidators = {}; this.processors = []; this.processorsReplace = false; + this.parser = undefined; } /** @@ -197,6 +203,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 +231,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 +282,7 @@ export class CatalogBuilder { private buildProcessors(): CatalogProcessor[] { const { config, logger, reader } = this.env; + const integrations = ScmIntegrations.fromConfig(config); this.checkDeprecatedReaderProcessors(); @@ -281,12 +304,13 @@ export class CatalogBuilder { if (!this.processorsReplace) { processors.push( new FileReaderProcessor(), + GithubDiscoveryProcessor.fromConfig(config, { logger }), GithubOrgReaderProcessor.fromConfig(config, { logger }), LdapOrgReaderProcessor.fromConfig(config, { logger }), 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..5936e3a747 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,9 +57,19 @@ 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) => { + /* + * NOTE: THIS METHOD IS DEPRECATED AND NOT RECOMMENDED TO USE + * + * Posting entities to this method has unclear semantics and will not + * properly subject them to limitations, processing, or resolution of + * relations. + * + * It stays around in the service for the time being, but may be + * removed or change semantics at any time without prior notice. + */ const body = await requireRequestBody(req); const [result] = await entitiesCatalog.batchAddOrUpdateEntities([ { entity: body as Entity, relations: [] }, @@ -67,7 +77,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 +85,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 +104,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 +117,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 +125,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 +148,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 2bb4d9ac54..ad536e45dc 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,44 @@ # @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 + +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.2.3 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 9b12250ca0..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.3", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "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/catalog-graphql" + }, + "keywords": [ + "backstage", + "graphql" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", + "@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", "cross-fetch": "^3.0.6", @@ -32,8 +42,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.0", + "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-graphql/src/graphql/module.test.ts b/plugins/catalog-graphql/src/graphql/module.test.ts index b359c4396b..e6e9d59df7 100644 --- a/plugins/catalog-graphql/src/graphql/module.test.ts +++ b/plugins/catalog-graphql/src/graphql/module.test.ts @@ -27,16 +27,11 @@ import { gql } from 'apollo-server'; describe('Catalog Module', () => { const worker = setupServer(); const mockCatalogBaseUrl = 'http://im.mock'; - const mockConfig = ConfigReader.fromConfigs([ - { - context: '', - data: { - backend: { - baseUrl: mockCatalogBaseUrl, - }, - }, + const mockConfig = new ConfigReader({ + backend: { + baseUrl: mockCatalogBaseUrl, }, - ]); + }); msw.setupDefaultHandlers(worker); diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index bdbb8681de..fce40aacfc 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,231 @@ # @backstage/plugin-catalog-import +## 0.4.2 + +### Patch Changes + +- a8953a9c9: This updates the `catalog-import` plugin to omit the default metadata namespace + field and also use the short form entity reference format for selected group owners. +- 968b588f7: Allows the CodeOwnersProcessor to set the owner automatically within the catalog-import plugin. This adds an additional checkbox that overrides the group selector and will omit the owner option in the generated catalog file yaml. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.4.1 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [491f3a0ec] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/integration@0.5.0 + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.4.0 + +### Minor Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 68dd79d83: The plugin has been refactored and is now based on a configurable state machine of 'analyze', 'prepare', 'review' & 'finish'. + Depending on the outcome of the 'analyze' stage, different flows are selected ('single-location', 'multiple-locations', 'no-location'). + Each flow can define it's own components that guide the user. + + During the refactoring, the `catalogRouteRef` property of the `CatalogImportPage` has been removed, so the `App.tsx` of the backstage apps need to be updated: + + ```diff + // packages/app/src/App.tsx + + } + + element={} + /> + ``` + +### Patch Changes + +- 753bb4c40: Flatten the options of the `CatalogImportPage` from the `options` property to the `pullRequest` property. + + ```diff + - + + + ``` + +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [72b96e880] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/integration@0.4.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- f3e75508d: Add description to Pull Request when registering a new component +- Updated dependencies [c6eeefa35] +- Updated dependencies [fb386b760] +- Updated dependencies [c911061b7] +- Updated dependencies [7c3ffc0cd] +- Updated dependencies [e7496dc3e] +- Updated dependencies [8ef71ed32] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [8dd0a906d] +- Updated dependencies [4eafdec4a] +- Updated dependencies [6b37c95bf] +- Updated dependencies [8c31c681c] +- Updated dependencies [7b98e7fee] +- Updated dependencies [178e09323] +- Updated dependencies [ac3560b42] +- Updated dependencies [0097057ed] + - @backstage/plugin-catalog-backend@0.5.0 + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/integration@0.1.4 + - @backstage/plugin-catalog@0.2.7 + +## 0.3.1 + +### Patch Changes + +- 79418ddb6: Align plugin ID and fix variable typo +- d2938af4c: Add register existing component instructions +- Updated dependencies [6e8bb3ac0] +- Updated dependencies [2527628e1] +- Updated dependencies [e708679d7] +- Updated dependencies [047c018c9] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [38d63fbe1] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/plugin-catalog-backend@0.4.0 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.3.0 ### Minor Changes diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index 1dc0d7cf89..97371ce0e7 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -1,21 +1,84 @@ -# Catalog import plugin +# Catalog Import -Welcome to the catalog-import plugin! +The Catalog Import Plugin provides a wizard to onboard projects with existing `catalog-info.yaml` files. +It also assists by creating pull requests in repositories where no `catalog-info.yaml` exists. -This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it. +![Catalog Import Plugin](./docs/catalog-import-screenshot.png) -When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import). +Current features: - +- Import `catalog-info.yaml` files from a URL in a repository of one of the supported Git integrations (example `https://github.com/backstage/backstage/catalog-info.yaml`). +- _[GitHub only]_ Search for all `catalog-info.yaml` files in a Git repository (example: `https://github.com/backstage/backstage`). +- _[GitHub only]_ Analyze a repository, generate a Component entity, and create a Pull Request to onboard the repository. -## Running +Some features are not yet available for all supported Git providers. -Just run the backstage. +## Getting Started -``` -yarn start && yarn --cwd packages/backend start +1. Install the Catalog Import Plugin: + +```bash +# packages/app + +yarn add @backstage/plugin-catalog-import ``` -## Usage +2. Add the plugin to the app: -Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL. +```ts +// packages/app/src/plugins.ts + +export { catalogImportPlugin } from '@backstage/plugin-catalog-import'; +``` + +3. Register the `CatalogImportPage` at the `/catalog-import` path: + +```tsx +// packages/app/src/App.tsx + +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; + +} />; +``` + +## Customizations + +### Disable the creation of Pull Requests + +The pull request feature can be disabled by options that are passed to the `CatalogImportPage`: + +```tsx +// packages/app/src/App.tsx + +} +/> +``` + +### Customize the title and body of the Pull Request + +The pull request form is filled with a default title and body. +This can be configured by options that are passed to the `CatalogImportPage`: + +```tsx +// packages/app/src/App.tsx + + ({ + title: 'My title', + body: 'My **markdown** body', + }), + }} + /> + } +/> +``` + +## Development + +Use `yarn start` to run a [development version](./dev/index.tsx) of the plugin that can be used to validate each flow with mocked data. diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index 812a5585d4..e794628e6c 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -14,7 +14,338 @@ * limitations under the License. */ +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Content, Header, InfoCard, Page } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; +import AlarmIcon from '@material-ui/icons/Alarm'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React from 'react'; +import { + AnalyzeResult, + CatalogImportApi, + catalogImportApiRef, + EntityListComponent, + ImportStepper, +} from '../src'; +import { ImportComponentPage } from '../src/components/ImportComponentPage'; -createDevApp().registerPlugin(plugin).render(); +const getEntityNames = (url: string): EntityName[] => [ + { + kind: 'Component', + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'component-a', + }, + { + kind: 'API', + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'api-a', + }, +]; + +const getEntities = (url: string): Entity[] => [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'component-a', + }, + }, + { + apiVersion: '1', + kind: 'API', + metadata: { + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'api-a', + }, + }, +]; + +const locations = [ + { + target: 'https://my-location-1', + entities: [ + { + kind: 'Domain', + namespace: 'default', + name: 'my-domain', + }, + { + kind: 'Group', + namespace: 'groups', + name: 'my-group', + }, + { + kind: 'Location', + namespace: 'default', + name: 'my-location', + }, + { + kind: 'System', + namespace: 'default', + name: 'my-system', + }, + { + kind: 'User', + namespace: 'users', + name: 'my-api', + }, + ], + }, + { + target: 'https://my-location-2', + entities: [ + { + kind: 'API', + namespace: 'default', + name: 'my-api', + }, + { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + { + kind: 'Location', + namespace: 'default', + name: 'my-location', + }, + ], + }, +]; + +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + ({ + getEntities: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return { + items: [ + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'default', + }, + spec: { + profile: { + displayName: 'Group A', + }, + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-b', + namespace: 'default', + }, + spec: { + profile: { + displayName: 'Group B', + }, + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'other', + }, + spec: { + profile: { + displayName: 'Group A', + }, + }, + }, + ] as Entity[], + }; + }, + + addLocation: async location => { + await new Promise(r => setTimeout(r, 1000)); + + return { + location, + entities: getEntities(location.target), + }; + }, + } as CatalogApi), + }) + .registerApi({ + api: catalogImportApiRef, + deps: {}, + factory: () => + ({ + analyzeUrl: async (url: string): Promise => { + await new Promise(r => setTimeout(r, 500)); + + switch (url) { + case 'https://0': + return { + type: 'repository', + url, + integrationType: 'github', + generatedEntities: getEntities(url), + }; + + case 'https://1': + case 'https://2/catalog-info.yaml': + case 'https://2/folder-a/catalog-info.yaml': + case 'https://2/folder-b/catalog-info.yaml': + return { + type: 'locations', + locations: [ + { + target: url.includes('/catalog-info.yaml') + ? url + : `${url}/catalog-info.yaml`, + entities: getEntityNames(url), + }, + ], + }; + + case 'https://2': { + const urls = [ + `${url}/catalog-info.yaml`, + `${url}/folder-a/catalog-info.yaml`, + `${url}/folder-b/catalog-info.yaml`, + ]; + + return { + type: 'locations', + locations: urls.map(u => ({ + target: u, + entities: getEntityNames(u), + })), + }; + } + + default: + throw new Error(`Invalid url ${url}`); + } + }, + + submitPullRequest: async ({ + repositoryUrl, + }): Promise<{ + link: string; + location: string; + }> => { + await new Promise(r => setTimeout(r, 2500)); + + return { + link: `${repositoryUrl}/pulls/1`, + location: `${repositoryUrl}/blob/catalog-info.yaml`, + }; + }, + } as CatalogImportApi), + }) + .addPage({ + title: 'Catalog Import', + element: , + }) + .addPage({ + title: 'Catalog Import 2', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + ), + }) + .addPage({ + title: 'Components', + element: ( + +
+ + + + + } + /> + + + + + + + + + + + } + locations={locations} + locationListItemIcon={() => } + onItemClick={() => {}} + /> + + + + + } + /> + + + + + } + withLinks + /> + + + + + + ), + }) + .render(); diff --git a/plugins/catalog-import/docs/catalog-import-screenshot.png b/plugins/catalog-import/docs/catalog-import-screenshot.png new file mode 100644 index 0000000000..eb8348b7ef Binary files /dev/null and b/plugins/catalog-import/docs/catalog-import-screenshot.png differ diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 74cb3635be..83c9a0394e 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.0", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/catalog-import" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,16 +30,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/plugin-catalog-backend": "^0.3.0", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/catalog-client": "^0.3.6", + "@backstage/core": "^0.6.3", + "@backstage/integration": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.1.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", - "@octokit/rest": "^18.0.6", - "git-url-parse": "^11.4.0", + "@octokit/rest": "^18.0.12", + "@types/react": "^16.9", + "git-url-parse": "^11.4.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.6.0", @@ -40,11 +51,12 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 49012764e2..5237e0339f 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -14,22 +14,38 @@ * limitations under the License. */ +import { EntityName } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core'; -import { PartialEntity } from '../util/types'; +import { PartialEntity } from '../types'; export const catalogImportApiRef = createApiRef({ - id: 'plugin.catalogimport.service', + id: 'plugin.catalog-import.service', description: 'Used by the catalog import plugin to make requests', }); +// result of the analyze state +export type AnalyzeResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + generatedEntities: PartialEntity[]; + }; + export interface CatalogImportApi { - submitPrToRepo(options: { - owner: string; - repo: string; + analyzeUrl(url: string): Promise; + + submitPullRequest(options: { + repositoryUrl: string; fileContent: string; + title: string; + body: string; }): Promise<{ link: string; location: string }>; - createRepositoryLocation(options: { location: string }): Promise; - generateEntityDefinitions(options: { - repo: string; - }): Promise; } 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..95082829c4 --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -0,0 +1,344 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 = { + repos: { + get: () => Promise.resolve({ data: { default_branch: 'main' } }), + createOrUpdateFileContents: jest.fn().mockImplementation(async () => {}), + }, + search: { + code: jest.fn(), + }, + git: { + getRef: async () => ({ + data: { object: { sha: 'any' } }, + }), + createRef: jest.fn().mockImplementation(async () => {}), + }, + pulls: { + create: jest.fn().mockImplementation(async () => ({ + data: { + html_url: 'http://pull/request/0', + }, + })), + }, +}; + +jest.doMock('@octokit/rest', () => { + class Octokit { + constructor() { + return octokit; + } + } + return { Octokit }; +}); + +// Mock the value to control which integrations are activated +jest.mock('./GitHub', () => ({ + getGithubIntegrationConfig: jest.fn(), +})); + +import { ConfigReader, OAuthApi, UrlPatternDiscovery } from '@backstage/core'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { msw } from '@backstage/test-utils'; +import { Octokit } from '@octokit/rest'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { CatalogImportClient } from './CatalogImportClient'; +import { getGithubIntegrationConfig } from './GitHub'; + +const server = setupServer(); + +describe('CatalogImportClient', () => { + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/catalog'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const githubAuthApi: jest.Mocked = { + getAccessToken: jest.fn(), + }; + const identityApi = { + getUserId: () => { + return 'user'; + }, + getProfile: () => { + return {}; + }, + getIdToken: () => { + return Promise.resolve('token'); + }, + signOut: () => { + return Promise.resolve(); + }, + }; + + const configApi = new ConfigReader({}); + + const catalogApi: jest.Mocked = { + getEntities: jest.fn(), + addLocation: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + let catalogImportClient: CatalogImportClient; + let getGithubIntegrationConfigFn: jest.Mock; + + beforeEach(() => { + catalogImportClient = new CatalogImportClient({ + discoveryApi, + githubAuthApi, + configApi, + identityApi, + catalogApi, + }); + + getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock; + getGithubIntegrationConfigFn.mockReset(); + }); + + describe('analyzeUrl', () => { + it('should add yaml location', async () => { + catalogApi.addLocation.mockResolvedValueOnce({ + location: { + id: 'id-0', + type: 'url', + target: 'http://example.com/folder/catalog-info.yaml', + }, + entities: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'my-entity', + namespace: 'my-namespace', + }, + }, + ], + }); + + await expect( + catalogImportClient.analyzeUrl( + 'http://example.com/folder/catalog-info.yaml', + ), + ).resolves.toEqual({ + locations: [ + { + entities: [ + { + kind: 'Component', + name: 'my-entity', + namespace: 'my-namespace', + }, + ], + target: 'http://example.com/folder/catalog-info.yaml', + }, + ], + type: 'locations', + }); + + expect(catalogApi.addLocation).toBeCalledTimes(1); + expect(catalogApi.addLocation.mock.calls[0][0]).toEqual({ + type: 'url', + target: 'http://example.com/folder/catalog-info.yaml', + dryRun: true, + }); + }); + + it('should ignore missing github integration', async () => { + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).rejects.toThrow(new Error('Invalid url')); + }); + + it('should find locations from github', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: {} as GitHubIntegrationConfig, + }); + + ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ + data: { + total_count: 2, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }); + + catalogApi.addLocation.mockImplementation(async ({ type, target }) => ({ + location: { + id: 'id-0', + type: type ?? 'url', + target, + }, + entities: [ + { + apiVersion: '1', + kind: 'k', + metadata: { + name: 'e', + namespace: 'n', + }, + }, + ], + })); + + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).resolves.toEqual({ + locations: [ + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/simple/path/catalog-info.yaml', + }, + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/co/mple/x/path/catalog-info.yaml', + }, + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/catalog-info.yaml', + }, + ], + type: 'locations', + }); + }); + + it('should find repository from github', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: {} as GitHubIntegrationConfig, + }); + + ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ + data: { total_count: 0, items: [] }, + }); + + server.use( + rest.post(`${mockBaseUrl}/analyze-location`, (req, res, ctx) => { + expect(req.body).toEqual({ + location: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }); + + return res( + ctx.json({ + generateEntities: [ + { + entity: { + kind: 'k', + metadata: { name: 'e', namespace: 'n' }, + }, + fields: [], + }, + ], + existingEntityFiles: [], + }), + ); + }), + ); + + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).resolves.toEqual({ + type: 'repository', + url: 'https://github.com/backstage/backstage', + integrationType: 'github', + generatedEntities: [ + { + kind: 'k', + metadata: { name: 'e', namespace: 'n' }, + }, + ], + }); + }); + }); + + describe('submitPullRequest', () => { + it('should create GitHub pull request', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: { + host: 'github.com', + } as GitHubIntegrationConfig, + }); + + await expect( + catalogImportClient.submitPullRequest({ + repositoryUrl: 'https://github.com/backstage/backstage', + fileContent: 'some content', + title: 'A title', + body: 'A body', + }), + ).resolves.toEqual({ + link: 'http://pull/request/0', + location: + 'https://github.com/backstage/backstage/blob/main/catalog-info.yaml', + }); + + expect( + ((new Octokit().git.createRef as any) as jest.Mock).mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + ref: 'refs/heads/backstage-integration', + sha: 'any', + }); + expect( + ((new Octokit().repos.createOrUpdateFileContents as any) as jest.Mock) + .mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + path: 'catalog-info.yaml', + message: 'Add catalog-info.yaml config file', + content: 'c29tZSBjb250ZW50', + branch: 'backstage-integration', + }); + expect( + ((new Octokit().pulls.create as any) as jest.Mock).mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + title: 'A title', + head: 'backstage-integration', + body: 'A body', + base: 'main', + }); + }); + }); +}); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 94ba260d55..b4b1ebe845 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -14,38 +14,135 @@ * limitations under the License. */ +import { CatalogApi } from '@backstage/catalog-client'; +import { EntityName } from '@backstage/catalog-model'; +import { + ConfigApi, + DiscoveryApi, + IdentityApi, + OAuthApi, +} from '@backstage/core'; +import { GitHubIntegrationConfig } from '@backstage/integration'; import { Octokit } from '@octokit/rest'; -import { DiscoveryApi, OAuthApi } from '@backstage/core'; -import { CatalogImportApi } from './CatalogImportApi'; -import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend'; -import { PartialEntity } from '../util/types'; +import { PartialEntity } from '../types'; +import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi'; +import { getGithubIntegrationConfig } from './GitHub'; export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; private readonly githubAuthApi: OAuthApi; + private readonly configApi: ConfigApi; + private readonly catalogApi: CatalogApi; constructor(options: { discoveryApi: DiscoveryApi; githubAuthApi: OAuthApi; + identityApi: IdentityApi; + configApi: ConfigApi; + catalogApi: CatalogApi; }) { this.discoveryApi = options.discoveryApi; this.githubAuthApi = options.githubAuthApi; + this.identityApi = options.identityApi; + this.configApi = options.configApi; + this.catalogApi = options.catalogApi; } - async generateEntityDefinitions({ + async analyzeUrl(url: string): Promise { + if (url.match(/\.ya?ml$/)) { + const location = await this.catalogApi.addLocation({ + type: 'url', + target: url, + dryRun: true, + }); + + return { + type: 'locations', + locations: [ + { + target: location.location.target, + entities: location.entities.map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + })), + }, + ], + }; + } + + const ghConfig = getGithubIntegrationConfig(this.configApi, url); + + if (ghConfig) { + // TODO: this could be part of the analyze-location endpoint + const locations = await this.checkGitHubForExistingCatalogInfo({ + ...ghConfig, + url, + }); + + if (locations.length > 0) { + return { + type: 'locations', + locations, + }; + } + + return { + type: 'repository', + integrationType: 'github', + url: url, + generatedEntities: await this.generateEntityDefinitions({ + repo: url, + }), + }; + } + + throw new Error('Invalid url'); + } + + async submitPullRequest({ + repositoryUrl, + fileContent, + title, + body, + }: { + repositoryUrl: string; + fileContent: string; + title: string; + body: string; + }): Promise<{ link: string; location: string }> { + const ghConfig = getGithubIntegrationConfig(this.configApi, repositoryUrl); + + if (ghConfig) { + return await this.submitGitHubPrToRepo({ + ...ghConfig, + fileContent, + title, + body, + }); + } + + throw new Error('unimplemented!'); + } + + // TODO: this could be part of the catalog api + private async generateEntityDefinitions({ repo, }: { repo: string; }): Promise { + const idToken = await this.identityApi.getIdToken(); const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`, { headers: { 'Content-Type': 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), }, method: 'POST', body: JSON.stringify({ - location: { type: 'github', target: repo }, + location: { type: 'url', target: repo }, }), }, ).catch(e => { @@ -57,49 +154,102 @@ 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({ - location, - }: { - location: string; - }): Promise { - const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, - { - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ - type: 'github', - target: location, - presence: 'optional', - }), - }, - ); - if (!response.ok) { - throw new Error( - `Received http response ${response.status}: ${response.statusText}`, - ); - } - } - - async submitPrToRepo({ + // TODO: this response should better be part of the analyze-locations response and scm-independent / implemented per scm + private async checkGitHubForExistingCatalogInfo({ + url, owner, repo, + githubIntegrationConfig, + }: { + url: string; + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise< + Array<{ + target: string; + entities: EntityName[]; + }> + > { + 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; + + return await Promise.all( + searchResult.data.items + .map( + i => `${url.replace(/[\/]*$/, '')}/blob/${defaultBranch}/${i.path}`, + ) + .map( + async i => + ({ + target: i, + entities: ( + await this.catalogApi.addLocation({ + type: 'url', + target: i, + dryRun: true, + }) + ).entities.map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + })), + } as { + target: string; + entities: EntityName[]; + }), + ), + ); + } + + return []; + } + + // TODO: extract this function and implement for non-github + private async submitGitHubPrToRepo({ + owner, + repo, + title, + body, fileContent, + githubIntegrationConfig, }: { owner: string; repo: string; + title: string; + body: string; fileContent: string; + githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }> { const token = await this.githubAuthApi.getAccessToken(['repo']); const octo = new Octokit({ auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, }); const branchName = 'backstage-integration'; @@ -160,12 +310,13 @@ export class CatalogImportClient implements CatalogImportApi { ); }); - const pullRequestRespone = await octo.pulls + const pullRequestResponse = await octo.pulls .create({ owner, repo, - title: `Add ${fileName} config file`, + title, head: branchName, + body, base: repoData.data.default_branch, }) .catch(e => { @@ -178,8 +329,8 @@ export class CatalogImportClient implements CatalogImportApi { }); return { - link: pullRequestRespone.data.html_url, - location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, + link: pullRequestResponse.data.html_url, + location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, }; } } diff --git a/plugins/catalog-import/src/api/GitHub.ts b/plugins/catalog-import/src/api/GitHub.ts new file mode 100644 index 0000000000..6a798ec201 --- /dev/null +++ b/plugins/catalog-import/src/api/GitHub.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 { ConfigApi } from '@backstage/core'; +import { ScmIntegrations } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; + +export const getGithubIntegrationConfig = ( + config: ConfigApi, + location: string, +) => { + const { name: repo, owner } = parseGitUrl(location); + + const scmIntegrations = ScmIntegrations.fromConfig(config); + const githubIntegrationConfig = scmIntegrations.github.byUrl(location); + + if (!githubIntegrationConfig) { + return undefined; + } + + return { + repo, + owner, + githubIntegrationConfig: githubIntegrationConfig.config, + }; +}; diff --git a/plugins/catalog-import/src/assets/catalog-import-screenshot.png b/plugins/catalog-import/src/assets/catalog-import-screenshot.png deleted file mode 100644 index 7ba8ae9eb5..0000000000 Binary files a/plugins/catalog-import/src/assets/catalog-import-screenshot.png and /dev/null differ diff --git a/plugins/catalog-import/src/components/Buttons/index.tsx b/plugins/catalog-import/src/components/Buttons/index.tsx new file mode 100644 index 0000000000..93dd96b399 --- /dev/null +++ b/plugins/catalog-import/src/components/Buttons/index.tsx @@ -0,0 +1,70 @@ +/* + * 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 { Button, CircularProgress } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { ComponentProps } from 'react'; + +const useStyles = makeStyles(theme => ({ + wrapper: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + position: 'relative', + }, + buttonProgress: { + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12, + }, + button: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + }, +})); + +export const NextButton = ( + props: ComponentProps & { loading?: boolean }, +) => { + const { loading, ...buttonProps } = props; + const classes = useStyles(); + + return ( +
+
+ ); +}; + +export const BackButton = (props: ComponentProps) => { + const classes = useStyles(); + + return ( + + ); +}; diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx deleted file mode 100644 index f3dda35003..0000000000 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ /dev/null @@ -1,197 +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, { 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 { - errorApiRef, - RouteRef, - StructuredMetadataTable, - useApi, -} from '@backstage/core'; -import parseGitUri from 'git-url-parse'; -import { PartialEntity } from '../util/types'; -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'; - -const getEntityCatalogPath = ({ - entity, - catalogRouteRef, -}: { - entity: PartialEntity; - catalogRouteRef: RouteRef; -}) => { - const relativeEntityPathInsideCatalog = generatePath( - entityRoute.path, - entityRouteParams(entity as Entity), - ); - - const resolvedAbsolutePath = resolvePath( - relativeEntityPathInsideCatalog, - catalogRouteRef.path, - )?.pathname; - - return resolvedAbsolutePath; -}; - -type Props = { - nextStep: (options?: { reset: boolean }) => void; - configFile: ConfigSpec; - savePRLink: (PRLink: string) => void; - catalogRouteRef: RouteRef; -}; - -const ComponentConfigDisplay = ({ - nextStep, - configFile, - savePRLink, - catalogRouteRef, -}: Props) => { - const [errorOccured, setErrorOccured] = useState(false); - const [submitting, setSubmitting] = useState(false); - const errorApi = useApi(errorApiRef); - const { submitPrToRepo, addLocation } = useGithubRepos(); - const onNext = useCallback(async () => { - try { - setSubmitting(true); - if (!parseGitUri(configFile.location).filepathtype) { - const result = await submitPrToRepo(configFile); - savePRLink(result.link); - setSubmitting(false); - nextStep(); - } else { - addLocation(configFile.location); - setSubmitting(false); - nextStep(); - } - } catch (e) { - setErrorOccured(true); - setSubmitting(false); - errorApi.post(e); - } - }, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]); - - return ( - - {!parseGitUri(configFile.location).filepathtype ? ( - - Following config object will be submitted in a pull request to the - repository{' '} - - {configFile.location} - {' '} - and added as a new location to the backend - - ) : ( - - Following config object will be added as a new location to the backend{' '} - - {configFile.location} - - - )} - - - {!parseGitUri(configFile.location).filepathtype ? ( -
{YAML.stringify(configFile.config)}
- ) : ( - - {configFile.config.map((entity: any, index: number) => { - const entityPath = getEntityCatalogPath({ - entity, - catalogRouteRef, - }); - return ( - - - - {entityPath} - - ), - }} - /> - - {index < configFile.config.length - 1 && ( - - )} - - ); - })} - - )} -
- - {submitting ? ( - - - - ) : null} - - - {errorOccured ? ( - - ) : null} - - -
- ); -}; - -export default ComponentConfigDisplay; diff --git a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx new file mode 100644 index 0000000000..d94a1cad19 --- /dev/null +++ b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx @@ -0,0 +1,165 @@ +/* + * 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 { Entity, EntityName } from '@backstage/catalog-model'; +import { + EntityRefLink, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; +import { + Collapse, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import ApartmentIcon from '@material-ui/icons/Apartment'; +import CategoryIcon from '@material-ui/icons/Category'; +import ExpandLessIcon from '@material-ui/icons/ExpandLess'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import GroupIcon from '@material-ui/icons/Group'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import MemoryIcon from '@material-ui/icons/Memory'; +import PersonIcon from '@material-ui/icons/Person'; +import WorkIcon from '@material-ui/icons/Work'; +import React, { useState } from 'react'; + +const useStyles = makeStyles(theme => ({ + nested: { + paddingLeft: theme.spacing(4), + }, +})); + +function sortEntities(entities: Array) { + return entities.sort((a, b) => + formatEntityRefTitle(a).localeCompare(formatEntityRefTitle(b)), + ); +} + +function getEntityIcon(entity: { kind: string }): React.ReactElement { + switch (entity.kind.toLowerCase()) { + case 'api': + return ; + + case 'component': + return ; + + case 'domain': + return ; + + case 'group': + return ; + + case 'location': + return ; + + case 'system': + return ; + + case 'user': + return ; + + default: + return ; + } +} + +type Props = { + locations: Array<{ target: string; entities: (Entity | EntityName)[] }>; + locationListItemIcon: (target: string) => React.ReactElement; + collapsed?: boolean; + firstListItem?: React.ReactElement; + onItemClick?: (target: string) => void; + withLinks?: boolean; +}; + +export const EntityListComponent = ({ + locations, + collapsed = false, + locationListItemIcon, + onItemClick, + firstListItem, + withLinks = false, +}: Props) => { + const classes = useStyles(); + + const [expandedUrls, setExpandedUrls] = useState([]); + + const handleClick = (url: string) => { + setExpandedUrls(urls => + urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url), + ); + }; + + return ( + + {firstListItem} + {locations.map(r => ( + + onItemClick?.call(this, r.target)} + > + {locationListItemIcon(r.target)} + + + + {collapsed && ( + + handleClick(r.target)}> + {expandedUrls.includes(r.target) ? ( + + ) : ( + + )} + + + )} + + + + + {sortEntities(r.entities).map(entity => ( + + {getEntityIcon(entity)} + + + ))} + + + + ))} + + ); +}; diff --git a/plugins/catalog-import/src/components/EntityListComponent/index.ts b/plugins/catalog-import/src/components/EntityListComponent/index.ts new file mode 100644 index 0000000000..fc0efc5481 --- /dev/null +++ b/plugins/catalog-import/src/components/EntityListComponent/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 { EntityListComponent } from './EntityListComponent'; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx deleted file mode 100644 index 9a4fd38a06..0000000000 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ /dev/null @@ -1,131 +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 { errorApiRef, useApi } from '@backstage/core'; -import { BackstageTheme } from '@backstage/theme'; -import { - Button, - FormControl, - FormHelperText, - TextField, -} from '@material-ui/core'; -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 { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; -import { catalogApiRef } from '@backstage/plugin-catalog'; - -const useStyles = makeStyles(theme => ({ - form: { - alignItems: 'flex-start', - display: 'flex', - flexFlow: 'column nowrap', - }, - submit: { - marginTop: theme.spacing(1), - }, -})); - -type Props = { - nextStep: () => void; - saveConfig: (configFile: ConfigSpec) => void; -}; - -export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { - const { register, handleSubmit, errors, formState } = useForm({ - mode: 'onChange', - }); - const classes = useStyles(); - const hasErrors = !!errors.componentLocation; - const dirty = formState?.isDirty; - const catalogApi = useApi(catalogApiRef); - - const isMounted = useMountedState(); - const errorApi = useApi(errorApiRef); - const { generateEntityDefinitions } = useGithubRepos(); - - const onSubmit = async (formData: Record) => { - const { componentLocation: target } = formData; - try { - if (!isMounted()) return; - const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; - - if (type === 'repo') { - saveConfig({ - type, - location: target, - config: await generateEntityDefinitions(target), - }); - } else { - const data = await catalogApi.addLocation({ target }); - saveConfig({ - type, - location: data.location.target, - config: data.entities, - }); - } - nextStep(); - } catch (e) { - errorApi.post(e); - } - }; - - return ( -
- - - - {errors.componentLocation && ( - - {errors.componentLocation.message} - - )} - - - - - ); -}; 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..4cadea85c0 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -0,0 +1,84 @@ +/* + * 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, + ConfigReader, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { ImportComponentPage } from './ImportComponentPage'; + +describe('', () => { + const identityApi = { + getUserId: () => { + return 'user'; + }, + getProfile: () => { + return {}; + }, + getIdToken: () => { + return Promise.resolve('token'); + }, + signOut: () => { + return Promise.resolve(); + }, + }; + + let apis: ApiRegistry; + + beforeEach(() => { + apis = ApiRegistry.with( + configApiRef, + new ConfigReader({ integrations: {} }), + ) + .with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any })) + .with( + catalogImportApiRef, + new CatalogImportClient({ + discoveryApi: {} as any, + githubAuthApi: { + getAccessToken: async () => 'token', + }, + identityApi, + configApi: {} as any, + catalogApi: {} as any, + }), + ); + }); + + it('renders without exploding', async () => { + await act(async () => { + const { getByText } = render( + wrapInTestApp( + + + , + ), + ); + + expect( + await getByText('Start tracking your component in Backstage'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index c2eb935e54..1d1a8056bd 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -14,96 +14,118 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid } from '@material-ui/core'; import { + ConfigApi, + configApiRef, + Content, + ContentHeader, + Header, InfoCard, Page, - Content, - Header, SupportButton, - ContentHeader, - RouteRef, + useApi, } from '@backstage/core'; -import { RegisterComponentForm } from './ImportComponentForm'; -import ImportStepper from './ImportStepper'; -import ComponentConfigDisplay from './ComponentConfigDisplay'; -import { ImportFinished } from './ImportFinished'; -import { PartialEntity } from '../util/types'; +import { Grid, Typography } from '@material-ui/core'; +import React from 'react'; +import { ImportStepper } from './ImportStepper'; +import { StepperProviderOpts } from './ImportStepper/defaults'; -export type ConfigSpec = { - type: 'repo' | 'file'; - location: string; - config: PartialEntity[]; -}; +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, -}: { - catalogRouteRef: RouteRef; -}) => { - const [activeStep, setActiveStep] = useState(0); - const [configFile, setConfigFile] = useState({ - type: 'repo', - location: '', - config: [], - }); - const [endLink, setEndLink] = useState(''); - const nextStep = (options?: { reset: boolean }) => { - setActiveStep(step => (options?.reset ? 0 : step + 1)); - }; +export const ImportComponentPage = (opts: StepperProviderOpts) => { + 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. TODO: Add more - information about what this is. + Start tracking your component in {appTitle} by adding it to the + software catalog. - - - - - ), - }, - { - step: 'Review', - content: ( - - ), - }, - { - step: 'Finish', - content: ( - - ), - }, - ]} - activeStep={activeStep} - nextStep={nextStep} - /> + + + + + + Enter the URL to your SCM repository to add it to {appTitle}. + + + Link to an existing entity file + + + Example:{' '} + + https://github.com/backstage/backstage/blob/master/catalog-info.yaml + + + + The wizard analyzes the file, previews the entities, and adds + them to the {appTitle} catalog. + + {repos.length > 0 && ( + <> + + Link to a {repositoryString} repository + + + Example: https://github.com/backstage/backstage + + + The wizard discovers all catalog-info.yaml{' '} + files in the repository, previews the entities, and adds + them to the {appTitle} catalog. + + {!opts?.pullRequest?.disable && ( + + If no entities are found, the wizard will prepare a Pull + Request that adds an example{' '} + catalog-info.yaml and prepares the {appTitle}{' '} + catalog to load all entities as soon as the Pull Request + is merged. + + )} + + )} + + + + diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx deleted file mode 100644 index caba678999..0000000000 --- a/plugins/catalog-import/src/components/ImportFinished.tsx +++ /dev/null @@ -1,58 +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 { Alert } from '@material-ui/lab'; -import { Button, Grid, Link } from '@material-ui/core'; - -type Props = { - type: 'repo' | 'file'; - nextStep: (options?: { reset: boolean }) => void; - PRLink: string; -}; - -export const ImportFinished = ({ nextStep, PRLink, type }: Props) => { - return ( - - - - {type === 'repo' - ? 'Pull requests have been successfully opened. You can start again to import more repositories' - : 'Entity added to catalog successfully'} - - - - {type === 'repo' ? ( - - View pull request on GitHub - - ) : null} - - - - ); -}; diff --git a/plugins/catalog-import/src/components/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper.tsx deleted file mode 100644 index 03752fa7d4..0000000000 --- a/plugins/catalog-import/src/components/ImportStepper.tsx +++ /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 React from 'react'; -import Stepper from '@material-ui/core/Stepper'; -import Step from '@material-ui/core/Step'; -import StepLabel from '@material-ui/core/StepLabel'; -import { StepContent } from '@material-ui/core'; - -type Props = { - steps: { step: string; content: React.ReactNode }[]; - activeStep: number; - nextStep: (options?: { reset: boolean }) => void; -}; - -export default function ImportStepper({ steps, activeStep }: Props) { - return ( - - {steps.map(({ step }) => { - const stepProps: { completed?: boolean } = {}; - return ( - - {step} - {steps[activeStep].content} - - ); - })} - - ); -} diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx new file mode 100644 index 0000000000..49fce69b5a --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.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 { + configApiRef, + InfoCard, + InfoCardVariants, + useApi, +} from '@backstage/core'; +import { Step, StepContent, Stepper } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useMemo } from 'react'; +import { ImportFlows, ImportState, useImportState } from '../useImportState'; +import { + defaultGenerateStepper, + defaultStepper, + StepConfiguration, + StepperProvider, + StepperProviderOpts, +} from './defaults'; + +const useStyles = makeStyles(() => ({ + stepperRoot: { + padding: 0, + }, +})); + +type Props = { + initialUrl?: string; + generateStepper?: ( + flow: ImportFlows, + defaults: StepperProvider, + ) => StepperProvider; + variant?: InfoCardVariants; + opts?: StepperProviderOpts; +}; + +export const ImportStepper = ({ + initialUrl, + generateStepper = defaultGenerateStepper, + variant, + opts, +}: Props) => { + const configApi = useApi(configApiRef); + const classes = useStyles(); + const state = useImportState({ initialUrl }); + + const states = useMemo( + () => generateStepper(state.activeFlow, defaultStepper), + [generateStepper, state.activeFlow], + ); + + const render = (step: StepConfiguration) => { + return ( + + {step.stepLabel} + {step.content} + + ); + }; + + return ( + + + {render( + states.analyze( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.prepare( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.review( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.finish( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + + + ); +}; diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx new file mode 100644 index 0000000000..dff2136aaa --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -0,0 +1,327 @@ +/* + * 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 { ConfigApi } from '@backstage/core'; +import { + Box, + Checkbox, + FormControlLabel, + FormHelperText, + StepLabel, + TextField, + Typography, +} from '@material-ui/core'; +import React from 'react'; +import { BackButton } from '../Buttons'; +import { StepFinishImportLocation } from '../StepFinishImportLocation'; +import { StepInitAnalyzeUrl } from '../StepInitAnalyzeUrl'; +import { + AutocompleteTextField, + StepPrepareCreatePullRequest, +} from '../StepPrepareCreatePullRequest'; +import { StepPrepareSelectLocations } from '../StepPrepareSelectLocations'; +import { StepReviewLocation } from '../StepReviewLocation'; +import { ImportFlows, ImportState } from '../useImportState'; + +export type StepperProviderOpts = { + pullRequest?: { + disable?: boolean; + preparePullRequest?: (apis: StepperApis) => { title: string; body: string }; + }; +}; + +type StepperApis = { + configApi: ConfigApi; +}; + +export type StepConfiguration = { + stepLabel: React.ReactElement; + content: React.ReactElement; +}; + +export type StepperProvider = { + analyze: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + prepare: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + review: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + finish: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; +}; + +function defaultPreparePullRequest(apis: StepperApis) { + const appTitle = apis.configApi.getOptionalString('app.title') ?? 'Backstage'; + const appBaseUrl = apis.configApi.getString('app.baseUrl'); + + return { + title: 'Add catalog-info.yaml config file', + body: `This pull request adds a **Backstage entity metadata file** \ +to this repository so that the component can be added to the \ +[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \ +the component will become available.\n\nFor more information, read an \ +[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`, + }; +} + +/** + * The default stepper generation function. + * + * Override this function to customize the import flow. Each flow should at + * least override the prepare operation. + * + * @param flow the name of the active flow + * @param defaults the default steps + */ +export function defaultGenerateStepper( + flow: ImportFlows, + defaults: StepperProvider, +): StepperProvider { + switch (flow) { + // the prepare step is skipped but the label of the step is updated + case 'single-location': + return { + ...defaults, + prepare: () => ({ + stepLabel: ( + + Discovered Locations: 1 + + } + > + Select Locations + + ), + content: <>, + }), + }; + + // let the user select one or more of the discovered locations in the prepare step + case 'multiple-locations': + return { + ...defaults, + prepare: (state, opts) => { + if (state.analyzeResult.type !== 'locations') { + return defaults.prepare(state, opts); + } + + return { + stepLabel: ( + + Discovered Locations: {state.analyzeResult.locations.length} + + } + > + Select Locations + + ), + content: ( + + ), + }; + }, + }; + + case 'no-location': + return { + ...defaults, + prepare: (state, opts) => { + if (state.analyzeResult.type !== 'repository') { + return defaults.prepare(state, opts); + } + + const { title, body } = ( + opts?.opts?.pullRequest?.preparePullRequest ?? + defaultPreparePullRequest + )(opts.apis); + + return { + stepLabel: Create Pull Request, + content: ( + ( + <> + + Pull Request Details + + + + + + + + Entity Configuration + + + + + {!values.useCodeowners && ( + + )} + + { + if (value) { + control.setValue('owner', ''); + } + }} + /> + } + label={ + <> + Use CODEOWNERS file as Entity Owner + + } + /> + + WARNING: This may fail is no CODEOWNERS file is found at + the target location. + + + )} + /> + ), + }; + }, + }; + + default: + return defaults; + } +} + +export const defaultStepper: StepperProvider = { + analyze: (state, { opts }) => ({ + stepLabel: Select URL, + content: ( + + ), + }), + + prepare: state => ({ + stepLabel: ( + Optional}> + Import Actions + + ), + content: , + }), + + review: state => ({ + stepLabel: Review, + content: ( + + ), + }), + + finish: state => ({ + stepLabel: Finish, + content: ( + + ), + }), +}; diff --git a/plugins/catalog-import/src/components/ImportStepper/index.ts b/plugins/catalog-import/src/components/ImportStepper/index.ts new file mode 100644 index 0000000000..8d205562a4 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { ImportStepper } from './ImportStepper'; +export { defaultGenerateStepper } from './defaults'; diff --git a/plugins/catalog-import/src/components/Router.tsx b/plugins/catalog-import/src/components/Router.tsx index 5175f8f077..fc74c32b30 100644 --- a/plugins/catalog-import/src/components/Router.tsx +++ b/plugins/catalog-import/src/components/Router.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ -import { RouteRef } from '@backstage/core'; import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { ImportComponentPage } from './ImportComponentPage'; +import { StepperProviderOpts } from './ImportStepper/defaults'; -export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => ( +export const Router = (opts: StepperProviderOpts) => ( - } - /> + } /> ); diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx new file mode 100644 index 0000000000..cc5eb9adef --- /dev/null +++ b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.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 { Link } from '@backstage/core'; +import { Grid, Typography } from '@material-ui/core'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React from 'react'; +import { BackButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { ReviewResult } from '../useImportState'; + +type Props = { + reviewResult: ReviewResult; + onReset: () => void; +}; + +export const StepFinishImportLocation = ({ reviewResult, onReset }: Props) => ( + <> + {reviewResult.type === 'repository' && ( + <> + + The following Pull Request has been opened:{' '} + + {reviewResult.pullRequest.url} + + + + + Your entities will be imported as soon as the Pull Request is merged. + + + )} + + + The following entities have been added to the catalog: + + + } + withLinks + /> + + + Register another + + +); diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts b/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts new file mode 100644 index 0000000000..5f3d9f425c --- /dev/null +++ b/plugins/catalog-import/src/components/StepFinishImportLocation/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 { StepFinishImportLocation } from './StepFinishImportLocation'; diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx new file mode 100644 index 0000000000..bfb2434926 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -0,0 +1,401 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, errorApiRef } from '@backstage/core'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult, catalogImportApiRef } from '../../api/'; +import { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl'; + +describe('', () => { + const catalogImportApi: jest.Mocked = { + analyzeUrl: jest.fn(), + submitPullRequest: jest.fn(), + }; + + const errorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const location = { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + const { getByRole } = render( + undefined} />, + { + wrapper: Wrapper, + }, + ); + + expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument(); + expect(getByRole('textbox', { name: /Repository/i })).toHaveValue(''); + }); + + it('should use default analysis url', async () => { + const { getByRole } = render( + undefined} + analysisUrl="https://default" + />, + { + wrapper: Wrapper, + }, + ); + + expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument(); + expect(getByRole('textbox', { name: /Repository/i })).toHaveValue( + 'https://default', + ); + }); + + it('should not analyze without url', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); + expect(onAnalysisFn).toBeCalledTimes(0); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze invalid value', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'http:/', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); + expect(onAnalysisFn).toBeCalledTimes(0); + expect(errorApi.post).toBeCalledTimes(0); + expect(getByText('Must start with https://.')).toBeInTheDocument(); + }); + + it('should analyze single location', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [location], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'single-location', + 'https://my-repository', + analyzeResult, + { prepareResult: analyzeResult }, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should analyze multiple locations', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [location, location], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-1', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'multiple-locations', + 'https://my-repository-1', + analyzeResult, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze with no locations', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-1', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText('There are no entities at this location'), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should analyze repository', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'component', + metadata: { + name: 'component-a', + }, + }, + ], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'no-location', + 'https://my-repository-2', + analyzeResult, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze repository without entities', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText("Couldn't generate entities for your repository"), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze repository if disabled', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'component', + metadata: { + name: 'component-a', + }, + }, + ], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText("Couldn't generate entities for your repository"), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should report unknown type to the errorapi', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(({ type: 'unknown' } as any) as AnalyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText( + 'Received unknown analysis result of type unknown. Please contact the support team.', + ), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(1); + expect(errorApi.post.mock.calls[0][0]).toMatchObject( + new Error( + 'Received unknown analysis result of type unknown. Please contact the support team.', + ), + ); + }); +}); diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx new file mode 100644 index 0000000000..a33fe388d8 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -0,0 +1,159 @@ +/* + * 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 { errorApiRef, useApi } from '@backstage/core'; +import { FormHelperText, Grid, TextField } from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { NextButton } from '../Buttons'; +import { ImportFlows, PrepareResult } from '../useImportState'; + +type FormData = { + url: string; +}; + +type Props = { + onAnalysis: ( + flow: ImportFlows, + url: string, + result: AnalyzeResult, + opts?: { prepareResult?: PrepareResult }, + ) => void; + disablePullRequest?: boolean; + analysisUrl?: string; +}; + +/** + * A form that lets the user input a url and analyze it for existing locations or potential entities. + * + * @param onAnalysis is called when the analysis was successful + * @param analysisUrl a url that can be used as a default value + * @param disablePullRequest if true, repositories without entities will abort the wizard + */ +export const StepInitAnalyzeUrl = ({ + onAnalysis, + analysisUrl = '', + disablePullRequest = false, +}: Props) => { + const errorApi = useApi(errorApiRef); + const catalogImportApi = useApi(catalogImportApiRef); + + const { register, handleSubmit, errors, watch } = useForm({ + mode: 'onTouched', + defaultValues: { + url: analysisUrl, + }, + }); + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(undefined); + + const handleResult = useCallback( + async ({ url }: FormData) => { + setSubmitted(true); + + try { + const analysisResult = await catalogImportApi.analyzeUrl(url); + + switch (analysisResult.type) { + case 'repository': + if ( + !disablePullRequest && + analysisResult.generatedEntities.length > 0 + ) { + onAnalysis('no-location', url, analysisResult); + } else { + setError("Couldn't generate entities for your repository"); + setSubmitted(false); + } + break; + + case 'locations': { + if (analysisResult.locations.length === 1) { + onAnalysis('single-location', url, analysisResult, { + prepareResult: analysisResult, + }); + } else if (analysisResult.locations.length > 1) { + onAnalysis('multiple-locations', url, analysisResult); + } else { + setError('There are no entities at this location'); + setSubmitted(false); + } + break; + } + + default: { + const err = `Received unknown analysis result of type ${ + (analysisResult as any).type + }. Please contact the support team.`; + setError(err); + setSubmitted(false); + + errorApi.post(new Error(err)); + break; + } + } + } catch (e) { + setError(e.message); + setSubmitted(false); + } + }, + [catalogImportApi, disablePullRequest, errorApi, onAnalysis], + ); + + return ( +
+ + (typeof value === 'string' && + value.match(/^https:\/\//) !== null) || + 'Must start with https://.', + }, + })} + required + /> + + {errors.url && ( + {errors.url.message} + )} + + {error && {error}} + + + + Analyze + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts new file mode 100644 index 0000000000..5d36c104c3 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/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 { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl'; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx new file mode 100644 index 0000000000..3231c3c6a1 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx @@ -0,0 +1,100 @@ +/* + * 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 { CircularProgress, TextField } from '@material-ui/core'; +import { TextFieldProps } from '@material-ui/core/TextField/TextField'; +import { Autocomplete } from '@material-ui/lab'; +import React from 'react'; +import { + Control, + Controller, + FieldErrors, + ValidationRules, +} from 'react-hook-form'; + +type Props = { + name: TFieldValue; + options: string[]; + required?: boolean; + + control?: Control>; + errors?: FieldErrors>; + rules?: ValidationRules; + + loading?: boolean; + loadingText?: string; + + helperText?: React.ReactNode; + errorHelperText?: string; + + textFieldProps?: Omit; +}; + +export const AutocompleteTextField = ({ + name, + options, + required, + control, + errors, + rules, + loading = false, + loadingText, + helperText, + errorHelperText, + textFieldProps = {}, +}: Props) => { + return ( + ( + onChange(v || '')} + onBlur={onBlur} + value={value} + autoSelect + freeSolo + renderInput={params => ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + {...textFieldProps} + /> + )} + /> + )} + /> + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx new file mode 100644 index 0000000000..0bf7746e6a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormHelperText, TextField } from '@material-ui/core'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { PreparePullRequestForm } from './PreparePullRequestForm'; + +describe('', () => { + it('renders without exploding', async () => { + const onSubmitFn = jest.fn(); + + const { getByRole } = render( + ( + <> + + {' '} + + )} + onSubmit={onSubmitFn} + />, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).toBeCalledTimes(1); + expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'default' }); + }); + + it('should register a text field', async () => { + const onSubmitFn = jest.fn(); + + const { getByRole, getByLabelText } = render( + ( + <> + + + + )} + onSubmit={onSubmitFn} + />, + ); + + await act(async () => { + userEvent.clear(getByLabelText('Main Field')); + await userEvent.type(getByLabelText('Main Field'), 'My Text'); + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).toBeCalledTimes(1); + expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'My Text' }); + }); + + it('registers required attribute', async () => { + const onSubmitFn = jest.fn(); + + const { queryByText, getByRole } = render( + ( + <> + + {errors.main && ( + + Error in required main field + + )} + {' '} + + )} + onSubmit={onSubmitFn} + />, + ); + + expect(queryByText('Error in required main field')).not.toBeInTheDocument(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).not.toBeCalled(); + expect(queryByText('Error in required main field')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx new file mode 100644 index 0000000000..9d59f4e10e --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.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 React from 'react'; +import { + SubmitHandler, + UnpackNestedValue, + useForm, + UseFormMethods, + UseFormOptions, +} from 'react-hook-form'; + +type Props> = Pick< + UseFormOptions, + 'defaultValues' +> & { + onSubmit: SubmitHandler; + + render: ( + props: Pick< + UseFormMethods, + 'errors' | 'register' | 'control' + > & { + values: UnpackNestedValue; + }, + ) => React.ReactNode; +}; + +/** + * A form wrapper that creates a form that is used to prepare a pull request. It + * hosts the form logic. + * + * @param defaultValues the default values of the form + * @param onSubmit a callback that is executed when the form is submitted + * (initiated by a button of type="submit") + * @param render render the form elements + */ +export const PreparePullRequestForm = < + TFieldValues extends Record +>({ + defaultValues, + onSubmit, + render, +}: Props) => { + const { handleSubmit, watch, control, register, errors } = useForm< + TFieldValues + >({ mode: 'onTouched', defaultValues }); + + return ( +
+ {render({ values: watch(), errors, register, control })} + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx new file mode 100644 index 0000000000..5bc22dbd42 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { makeStyles } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; + +const useStyles = makeStyles({ + displayNone: { + display: 'none', + }, +}); + +const entities: Entity[] = [ + { + apiVersion: '1', + kind: 'Kind', + metadata: { + name: 'name', + }, + }, + { + apiVersion: '1', + kind: 'Kind_2', + metadata: { + name: 'name', + }, + }, +]; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).toBeVisible(); + }); + + it('renders card with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).not.toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).not.toBeVisible(); + }); + + it('renders with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).not.toBeVisible(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx new file mode 100644 index 0000000000..c10c011820 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx @@ -0,0 +1,56 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { CodeSnippet } from '@backstage/core'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; +import React from 'react'; +import YAML from 'yaml'; + +type Props = { + repositoryUrl: string; + entities: Entity[]; + classes?: { card?: string; cardContent?: string }; +}; + +export const PreviewCatalogInfoComponent = ({ + repositoryUrl, + entities, + classes, +}: Props) => { + return ( + + {`${repositoryUrl.replace( + /[\/]*$/, + '', + )}/catalog-info.yaml`} + } + /> + + + YAML.stringify(e)) + .join('---\n') + .trim()} + language="yaml" + /> + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx new file mode 100644 index 0000000000..1556bd9b41 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeStyles } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; + +const useStyles = makeStyles({ + displayNone: { + display: 'none', + }, +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).toBeVisible(); + }); + + it('renders card with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).not.toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).not.toBeVisible(); + }); + + it('renders with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).not.toBeVisible(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx new file mode 100644 index 0000000000..01af05d43a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx @@ -0,0 +1,40 @@ +/* + * 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 { MarkdownContent } from '@backstage/core'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; +import React from 'react'; + +type Props = { + title: string; + description: string; + classes?: { card?: string; cardContent?: string }; +}; + +export const PreviewPullRequestComponent = ({ + title, + description, + classes, +}: Props) => { + return ( + + + + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx new file mode 100644 index 0000000000..bef0926192 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -0,0 +1,329 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TextField } from '@material-ui/core'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { + generateEntities, + StepPrepareCreatePullRequest, +} from './StepPrepareCreatePullRequest'; + +describe('', () => { + const catalogImportApi: jest.Mocked = { + analyzeUrl: jest.fn(), + submitPullRequest: jest.fn(), + }; + + const catalogApi: jest.Mocked = { + getEntities: jest.fn(), + addLocation: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const onPrepareFn = jest.fn(); + const analyzeResult = { + type: 'repository', + url: 'https://my-repository', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'default', + }, + spec: { + owner: 'my-owner', + }, + }, + ], + } as Extract; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] })); + + await act(async () => { + const { getByText } = render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + const title = getByText('My title'); + const description = getByText('body', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).toBeVisible(); + }); + }); + + it('should submit created PR', async () => { + catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] })); + catalogImportApi.submitPullRequest.mockReturnValue( + Promise.resolve({ + location: 'https://my/location.yaml', + link: 'https://my/pull', + }), + ); + + await act(async () => { + await render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + await userEvent.type(await screen.getByLabelText('name'), '-changed'); + await userEvent.type(await screen.getByLabelText('owner'), '-changed'); + await userEvent.click( + await screen.getByRole('button', { name: /Create PR/i }), + ); + }); + + expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1); + expect(catalogImportApi.submitPullRequest.mock.calls[0]).toMatchObject([ + { + body: 'My **body**', + fileContent: `apiVersion: "1" +kind: Component +metadata: + name: my-component-changed + namespace: default +spec: + owner: my-owner-changed +`, + repositoryUrl: 'https://my-repository', + title: 'My title', + }, + ]); + expect(onPrepareFn).toBeCalledTimes(1); + expect(onPrepareFn.mock.calls[0]).toMatchObject([ + { + type: 'repository', + url: 'https://my-repository', + integrationType: 'github', + pullRequest: { + url: 'https://my/pull', + }, + locations: [ + { + entities: [ + { + kind: 'Component', + name: 'my-component-changed', + namespace: 'default', + }, + ], + target: 'https://my/location.yaml', + }, + ], + }, + { + notRepeatable: true, + }, + ]); + }); + + it('should show error message', async () => { + catalogApi.getEntities.mockResolvedValueOnce({ items: [] }); + catalogImportApi.submitPullRequest.mockRejectedValueOnce( + new Error('some error'), + ); + + await act(async () => { + await render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + await userEvent.click( + await screen.getByRole('button', { name: /Create PR/i }), + ); + }); + + expect(screen.getByText('some error')).toBeInTheDocument(); + expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1); + expect(onPrepareFn).toBeCalledTimes(0); + }); + + it('should load groups', async () => { + const renderFormFieldsFn = jest.fn(); + catalogApi.getEntities.mockReturnValue( + Promise.resolve({ + items: [ + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'my-group', + }, + }, + ], + }), + ); + + await act(async () => { + await render( + , + { + wrapper: Wrapper, + }, + ); + }); + + expect(catalogApi.getEntities).toBeCalledTimes(1); + expect(renderFormFieldsFn).toBeCalled(); + expect(renderFormFieldsFn.mock.calls[0][0]).toMatchObject({ + groups: [], + groupsLoading: true, + }); + expect( + renderFormFieldsFn.mock.calls[ + renderFormFieldsFn.mock.calls.length - 1 + ][0], + ).toMatchObject({ groups: ['my-group'], groupsLoading: false }); + }); + + describe('generateEntities', () => { + it.each([[undefined], [null]])( + 'should not include blank namespace for %s', + namespace => { + expect( + generateEntities( + [{ metadata: { namespace: namespace as any } }], + 'my-component', + 'group-1', + ), + ).toEqual([ + expect.objectContaining({ + metadata: expect.not.objectContaining({ + namespace: 'default', + }), + }), + ]); + }, + ); + + it.each([['default'], ['my-namespace']])( + 'should include explicit namespace %s', + namespace => { + expect( + generateEntities( + [{ metadata: { namespace } }], + 'my-component', + 'group-1', + ), + ).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ + namespace, + }), + }), + ]); + }, + ); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx new file mode 100644 index 0000000000..c34d5f374b --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -0,0 +1,256 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { + catalogApiRef, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; +import { Box, FormHelperText, Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useCallback, useState } from 'react'; +import { UnpackNestedValue, UseFormMethods } from 'react-hook-form'; +import { useAsync } from 'react-use'; +import YAML from 'yaml'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { PartialEntity } from '../../types'; +import { BackButton, NextButton } from '../Buttons'; +import { PrepareResult } from '../useImportState'; +import { PreparePullRequestForm } from './PreparePullRequestForm'; +import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; +import { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; + +const useStyles = makeStyles(theme => ({ + previewCard: { + marginTop: theme.spacing(1), + }, + previewCardContent: { + paddingTop: 0, + }, +})); + +type FormData = { + title: string; + body: string; + componentName: string; + owner: string; + useCodeowners: boolean; +}; + +type Props = { + analyzeResult: Extract; + onPrepare: ( + result: PrepareResult, + opts?: { notRepeatable?: boolean }, + ) => void; + onGoBack?: () => void; + + defaultTitle: string; + defaultBody: string; + + renderFormFields: ( + props: Pick, 'errors' | 'register' | 'control'> & { + values: UnpackNestedValue; + groups: string[]; + groupsLoading: boolean; + }, + ) => React.ReactNode; +}; + +export function generateEntities( + entities: PartialEntity[], + componentName: string, + owner?: string, +): Entity[] { + return entities.map(e => ({ + ...e, + apiVersion: e.apiVersion!, + kind: e.kind!, + metadata: { + ...e.metadata, + name: componentName, + }, + spec: { + ...e.spec, + ...(owner ? { owner } : {}), + }, + })); +} + +export const StepPrepareCreatePullRequest = ({ + analyzeResult, + onPrepare, + onGoBack, + renderFormFields, + defaultTitle, + defaultBody, +}: Props) => { + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + const catalogInfoApi = useApi(catalogImportApiRef); + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(); + + const { loading: groupsLoading, value: groups } = useAsync(async () => { + const groupEntities = await catalogApi.getEntities({ + filter: { kind: 'group' }, + }); + + return groupEntities.items + .map(e => formatEntityRefTitle(e, { defaultKind: 'group' })) + .sort(); + }); + + const handleResult = useCallback( + async (data: FormData) => { + setSubmitted(true); + + try { + const pr = await catalogInfoApi.submitPullRequest({ + repositoryUrl: analyzeResult.url, + title: data.title, + body: data.body, + fileContent: generateEntities( + analyzeResult.generatedEntities, + data.componentName, + data.owner, + ) + .map(e => YAML.stringify(e)) + .join('---\n'), + }); + + onPrepare( + { + type: 'repository', + url: analyzeResult.url, + integrationType: analyzeResult.integrationType, + pullRequest: { + url: pr.link, + }, + locations: [ + { + target: pr.location, + entities: generateEntities( + analyzeResult.generatedEntities, + data.componentName, + data.owner, + ).map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace!, + name: e.metadata.name, + })), + }, + ], + }, + { notRepeatable: true }, + ); + } catch (e) { + setError(e.message); + setSubmitted(false); + } + }, + [ + analyzeResult.generatedEntities, + analyzeResult.integrationType, + analyzeResult.url, + catalogInfoApi, + onPrepare, + ], + ); + + return ( + <> + + You entered a link to a {analyzeResult.integrationType} repository but + we didn't found a catalog-info.yaml. Use this form to + create a Pull Request that creates an initial{' '} + catalog-info.yaml. + + + + onSubmit={handleResult} + defaultValues={{ + title: defaultTitle, + body: defaultBody, + owner: + (analyzeResult.generatedEntities[0]?.spec?.owner as string) || '', + componentName: + analyzeResult.generatedEntities[0]?.metadata?.name || '', + useCodeowners: false, + }} + render={({ values, errors, control, register }) => ( + <> + {renderFormFields({ + values, + errors, + register, + control, + groups: groups ?? [], + groupsLoading, + })} + + + Preview Pull Request + + + + + + Preview Entities + + + + + {error && {error}} + + + {onGoBack && ( + + )} + + Create PR + + + + )} + /> + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts new file mode 100644 index 0000000000..119feee4a1 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { AutocompleteTextField } from './AutocompleteTextField'; +export { PreparePullRequestForm } from './PreparePullRequestForm'; +export { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; +export { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; +export { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx new file mode 100644 index 0000000000..82da9b9572 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx @@ -0,0 +1,202 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult } from '../../api'; +import { StepPrepareSelectLocations } from './StepPrepareSelectLocations'; + +describe('', () => { + const analyzeResult = { + type: 'locations', + locations: [ + { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }, + { + target: 'url-2', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + { + kind: 'api', + namespace: 'default', + name: 'name', + }, + ], + }, + ], + } as Extract; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + const { getByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + }); + + it('should select and deselect all', async () => { + const { getByRole, getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + checkboxes.forEach(c => expect(c).not.toBeChecked()); + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Select All/i })); + }); + + checkboxes.forEach(c => expect(c).toBeChecked()); + expect(getByRole('button', { name: /Review/i })).not.toBeDisabled(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Select All/i })); + }); + + checkboxes.forEach(c => expect(c).not.toBeChecked()); + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + }); + + it('should preselect prepared locations', async () => { + const { getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it('should select items', async () => { + const { getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + checkboxes.forEach(c => expect(c).not.toBeChecked()); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + checkboxes.forEach(c => expect(c).not.toBeChecked()); + }); + + it('should go back', async () => { + const onGoBack = jest.fn(); + + const { getByRole } = render( + undefined} + onGoBack={onGoBack} + />, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Back/i })); + }); + + expect(onGoBack).toBeCalledTimes(1); + }); + + it('should submit', async () => { + const onPrepare = jest.fn(); + + const { getAllByRole, getByRole } = render( + undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Review/i })); + }); + + expect(onPrepare).toBeCalledTimes(1); + expect(onPrepare.mock.calls[0][0]).toMatchObject({ + type: 'locations', + locations: [ + { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }, + ], + } as Extract); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx new file mode 100644 index 0000000000..d44472fb5a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx @@ -0,0 +1,124 @@ +/* + * 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 { + Checkbox, + Grid, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; +import { AnalyzeResult } from '../../api'; +import { BackButton, NextButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { PrepareResult } from '../useImportState'; + +type Props = { + analyzeResult: Extract; + prepareResult?: PrepareResult; + onPrepare: (result: PrepareResult) => void; + onGoBack?: () => void; +}; + +/** + * A form that lets a user select one of a list of locations to import + * + * @param analyzeResult the result of the analysis + * @param prepareResult the selectected locations from a previous step + * @param onPrepare called after the selection + * @param onGoBack called to go back to the previous step + */ +export const StepPrepareSelectLocations = ({ + analyzeResult, + prepareResult, + onPrepare, + onGoBack, +}: Props) => { + const [selectedUrls, setSelectedUrls] = useState( + prepareResult?.locations.map(l => l.target) || [], + ); + + const handleResult = useCallback(async () => { + onPrepare({ + type: 'locations', + locations: analyzeResult.locations.filter((l: any) => + selectedUrls.includes(l.target), + ), + }); + }, [analyzeResult.locations, onPrepare, selectedUrls]); + + const onItemClick = (url: string) => { + setSelectedUrls(urls => + urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url), + ); + }; + + const onSelectAll = () => { + setSelectedUrls(urls => + urls.length < analyzeResult.locations.length + ? analyzeResult.locations.map(l => l.target) + : [], + ); + }; + + return ( + <> + + Select one or more locations that are present in your git repository: + + + + + 0 && + selectedUrls.length < analyzeResult.locations.length + } + tabIndex={-1} + disableRipple + /> + + + + } + onItemClick={onItemClick} + locations={analyzeResult.locations} + locationListItemIcon={target => ( + + )} + collapsed + /> + + + {onGoBack && } + + Review + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts b/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts new file mode 100644 index 0000000000..db98a63be0 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/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 { StepPrepareSelectLocations } from './StepPrepareSelectLocations'; diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx new file mode 100644 index 0000000000..eb2268f212 --- /dev/null +++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx @@ -0,0 +1,134 @@ +/* + * 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 { configApiRef, Link, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { FormHelperText, Grid, Typography } from '@material-ui/core'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React, { useCallback, useState } from 'react'; +import { BackButton, NextButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { PrepareResult, ReviewResult } from '../useImportState'; + +type Props = { + prepareResult: PrepareResult; + onReview: (result: ReviewResult) => void; + onGoBack?: () => void; +}; + +export const StepReviewLocation = ({ + prepareResult, + onReview, + onGoBack, +}: Props) => { + const catalogApi = useApi(catalogApiRef); + const configApi = useApi(configApiRef); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(); + + const handleImport = useCallback(async () => { + setSubmitted(true); + try { + const result = await Promise.all( + prepareResult.locations.map(l => + catalogApi.addLocation({ + type: 'url', + target: l.target, + presence: + prepareResult.type === 'repository' ? 'optional' : 'required', + }), + ), + ); + + onReview({ + ...prepareResult, + locations: result.map(r => ({ + target: r.location.target, + entities: r.entities, + })), + }); + } catch (e) { + // TODO: this error should be handled differently. We add it as 'optional' and + // it is not uncommon that a PR has not been merged yet. + if ( + prepareResult.type === 'repository' && + e.message.startsWith( + 'Location was added but has no entities specified yet', + ) + ) { + onReview({ + ...prepareResult, + locations: prepareResult.locations.map(l => ({ + target: l.target, + entities: [], + })), + }); + } else { + setError(e.message); + setSubmitted(false); + } + } + }, [prepareResult, onReview, catalogApi]); + + return ( + <> + {prepareResult.type === 'repository' && ( + <> + + The following Pull Request has been opened:{' '} + + {prepareResult.pullRequest.url} + + + + + You can already import the location and {appTitle} will fetch the + entities as soon as the Pull Request is merged. + + + )} + + + The following entities will be added to the catalog: + + + } + /> + + {error && {error}} + + + {onGoBack && } + handleImport()} + > + Import + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepReviewLocation/index.ts b/plugins/catalog-import/src/components/StepReviewLocation/index.ts new file mode 100644 index 0000000000..2c93e98437 --- /dev/null +++ b/plugins/catalog-import/src/components/StepReviewLocation/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 { StepReviewLocation } from './StepReviewLocation'; diff --git a/plugins/catalog-import/src/components/index.ts b/plugins/catalog-import/src/components/index.ts new file mode 100644 index 0000000000..180752c873 --- /dev/null +++ b/plugins/catalog-import/src/components/index.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 * from './ImportStepper'; +export * from './EntityListComponent'; +export * from './StepInitAnalyzeUrl'; +export * from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/components/useImportState.test.tsx b/plugins/catalog-import/src/components/useImportState.test.tsx new file mode 100644 index 0000000000..d1ef3184ad --- /dev/null +++ b/plugins/catalog-import/src/components/useImportState.test.tsx @@ -0,0 +1,372 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { cleanup } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { AnalyzeResult } from '../api'; + +import { + ImportState, + PrepareResult, + ReviewResult, + useImportState, +} from './useImportState'; + +describe('useImportState', () => { + const as = ( + curr: ImportState, + _: T, + ) => curr as Extract; + + const locationAP: AnalyzeResult & PrepareResult = { + type: 'locations', + locations: [ + { + target: 'https://0', + entities: [] as EntityName[], + }, + ], + }; + + const locationR: ReviewResult = { + type: 'locations', + locations: [ + { + target: 'https://0', + entities: [] as Entity[], + }, + ], + }; + + it('should use initial url', async () => { + const { result } = renderHook(() => + useImportState({ initialUrl: 'http://my-url' }), + ); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: 'http://my-url', + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + }); + + describe('onAnalysis & onPrepare & onReview & onReset', () => { + it('should work', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 1, + analysisUrl: 'http://my-url', + activeState: 'prepare', + analyzeResult: locationAP, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'prepare').onPrepare(locationAP); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 2, + analysisUrl: 'http://my-url', + activeState: 'review', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 3, + analysisUrl: 'http://my-url', + activeState: 'finish', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: locationR, + }); + + act(() => result.current.onReset()); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: locationR, + }); + }); + + it('should work skipped', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + { + prepareResult: locationAP, + }, + ); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 2, + analysisUrl: 'http://my-url', + activeState: 'review', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 3, + analysisUrl: 'http://my-url', + activeState: 'finish', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: locationR, + }); + }); + + it('should ignore on invalid state', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + // state 'analyze' + act(() => { + as(result.current, 'prepare').onPrepare(locationAP); + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current.activeState).toBe('analyze'); + expect(result.current.activeFlow).toBe('unknown'); + + // switch state to 'prepare' + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + + // state 'prepare' + act(() => { + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ); + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current.activeState).toBe('prepare'); + expect(result.current.activeFlow).toBe('single-location'); + + // switch to 'review' + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + + // state 'review' + act(() => { + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ); + as(result.current, 'prepare').onPrepare({ + type: 'locations', + locations: [], + }); + }); + + expect(result.current.activeState).toBe('review'); + expect(result.current.activeFlow).toBe('single-location'); + expect( + as(result.current, 'prepare').prepareResult!.locations, + ).not.toEqual([]); + + // switch to 'finish' + act(() => as(result.current, 'review').onReview(locationR)); + + expect(result.current.activeState).toBe('finish'); + expect(result.current.activeFlow).toBe('single-location'); + }); + }); + + describe('onGoBack', () => { + it('should work', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + expect(result.current.activeStepNumber).toBe(1); + + expect(result.current.onGoBack).not.toBeUndefined(); + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(0); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + expect(result.current.activeStepNumber).toBe(2); + + expect(result.current.onGoBack).not.toBeUndefined(); + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(1); + + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + act(() => as(result.current, 'review').onReview(locationR)); + expect(result.current.activeStepNumber).toBe(3); + }); + + it('should work for skipped', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + { + prepareResult: locationAP, + }, + ), + ); + expect(result.current.activeStepNumber).toBe(2); + expect(result.current.onGoBack).not.toBeUndefined(); + + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + }); + + describe('should consider prepareNotRepeatable', () => { + it('as true', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ), + ); + act(() => + as(result.current, 'prepare').onPrepare(locationAP, { + notRepeatable: true, + }), + ); + + expect(result.current.onGoBack).toBeUndefined(); + }); + + it('as false', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ), + ); + act(() => + as(result.current, 'prepare').onPrepare(locationAP, { + notRepeatable: false, + }), + ); + + expect(result.current.onGoBack).not.toBeUndefined(); + }); + }); + }); +}); diff --git a/plugins/catalog-import/src/components/useImportState.ts b/plugins/catalog-import/src/components/useImportState.ts new file mode 100644 index 0000000000..f042c991ef --- /dev/null +++ b/plugins/catalog-import/src/components/useImportState.ts @@ -0,0 +1,291 @@ +/* + * 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 { Entity, EntityName } from '@backstage/catalog-model'; +import { useReducer } from 'react'; +import { AnalyzeResult } from '../api'; + +// the configuration of the stepper +export type ImportFlows = + | 'unknown' + | 'single-location' + | 'multiple-locations' + | 'no-location'; + +// the available states of the stepper +type ImportStateTypes = 'analyze' | 'prepare' | 'review' | 'finish'; + +// result of the prepare state +export type PrepareResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + pullRequest: { + url: string; + }; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + }; + +// result of the review result +export type ReviewResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: Entity[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + pullRequest: { + url: string; + }; + locations: Array<{ + target: string; + entities: Entity[]; + }>; + }; + +// function type for the 'analysis' -> 'prepare'/'review' transition +type onAnalysisFn = ( + flow: ImportFlows, + url: string, + result: AnalyzeResult, + opts?: { prepareResult?: PrepareResult }, +) => void; + +// function type for the 'prepare' -> 'review' transition +type onPrepareFn = ( + result: PrepareResult, + opts?: { notRepeatable?: boolean }, +) => void; + +// function type for the 'review' -> 'finish' transition +type onReviewFn = (result: ReviewResult) => void; + +// the type interfaces that are available in each state. every state provides +// already known information and means to go to the next, or the previous step. +type State = + | { + activeState: 'analyze'; + onAnalysis: onAnalysisFn; + } + | { + activeState: 'prepare'; + analyzeResult: AnalyzeResult; + prepareResult?: PrepareResult; + onPrepare: onPrepareFn; + } + | { + activeState: 'review'; + analyzeResult: AnalyzeResult; + prepareResult: PrepareResult; + onReview: onReviewFn; + } + | { + activeState: 'finish'; + analyzeResult: AnalyzeResult; + prepareResult: PrepareResult; + reviewResult: ReviewResult; + }; + +export type ImportState = State & { + activeFlow: ImportFlows; + activeStepNumber: number; + analysisUrl?: string; + + onGoBack?: () => void; + onReset: () => void; +}; + +type ReducerActions = + | { type: 'onAnalysis'; args: Parameters } + | { type: 'onPrepare'; args: Parameters } + | { type: 'onReview'; args: Parameters } + | { type: 'onGoBack' } + | { type: 'onReset'; initialUrl?: string }; + +type ReducerState = { + activeFlow: ImportFlows; + activeState: ImportStateTypes; + analysisUrl?: string; + analyzeResult?: AnalyzeResult; + prepareResult?: PrepareResult; + reviewResult?: ReviewResult; + + previousStates: ImportStateTypes[]; +}; + +function init(initialUrl?: string): ReducerState { + return { + activeFlow: 'unknown', + activeState: 'analyze', + analysisUrl: initialUrl, + previousStates: [], + }; +} + +function reducer(state: ReducerState, action: ReducerActions): ReducerState { + switch (action.type) { + case 'onAnalysis': { + if (state.activeState !== 'analyze') { + return state; + } + + const { activeState, previousStates } = state; + const [activeFlow, analysisUrl, analyzeResult, opts] = action.args; + + return { + ...state, + analysisUrl, + activeFlow, + analyzeResult, + prepareResult: opts?.prepareResult, + + activeState: opts?.prepareResult === undefined ? 'prepare' : 'review', + previousStates: previousStates.concat(activeState), + }; + } + + case 'onPrepare': { + if (state.activeState !== 'prepare') { + return state; + } + + const { activeState, previousStates } = state; + const [prepareResult, opts] = action.args; + + return { + ...state, + prepareResult, + + activeState: 'review', + previousStates: opts?.notRepeatable + ? [] + : previousStates.concat(activeState), + }; + } + + case 'onReview': { + if (state.activeState !== 'review') { + return state; + } + + const { activeState, previousStates } = state; + const [reviewResult] = action.args; + + return { + ...state, + reviewResult, + + activeState: 'finish', + previousStates: previousStates.concat(activeState), + }; + } + + case 'onGoBack': { + const { activeState, previousStates } = state; + + return { + ...state, + + activeState: + previousStates.length > 0 + ? previousStates[previousStates.length - 1] + : activeState, + previousStates: previousStates.slice(0, previousStates.length - 1), + }; + } + + case 'onReset': + return { + ...init(action.initialUrl), + + // we keep the old reviewResult since the form is animated and an + // undefined value might crash the last step. + reviewResult: state.reviewResult, + }; + + default: + throw new Error(); + } +} + +/** + * A hook that manages the state machine of the form. It handles different flows + * which each can implement up to four states: + * 1. analyze + * 2. preview (skippable from analyze->review) + * 3. review + * 4. finish + * + * @param options options + */ +export const useImportState = (options?: { + initialUrl?: string; +}): ImportState => { + const [state, dispatch] = useReducer(reducer, options?.initialUrl, init); + + const { activeFlow, activeState, analysisUrl, previousStates } = state; + + return { + activeFlow, + activeState, + activeStepNumber: ['analyze', 'prepare', 'review', 'finish'].indexOf( + activeState, + ), + analysisUrl: analysisUrl, + + analyzeResult: state.analyzeResult!, + prepareResult: state.prepareResult!, + reviewResult: state.reviewResult!, + + onAnalysis: (flow, url, result, opts) => + dispatch({ + type: 'onAnalysis', + args: [flow, url, result, opts], + }), + + onPrepare: (result, opts) => + dispatch({ + type: 'onPrepare', + args: [result, opts], + }), + + onReview: result => dispatch({ type: 'onReview', args: [result] }), + + onGoBack: + previousStates.length > 0 + ? () => dispatch({ type: 'onGoBack' }) + : undefined, + + onReset: () => + dispatch({ type: 'onReset', initialUrl: options?.initialUrl }), + }; +}; diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index ff7857cacd..562e0499f3 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -14,5 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + catalogImportPlugin, + catalogImportPlugin as plugin, + CatalogImportPage, +} from './plugin'; export { Router } from './components/Router'; +export * from './components'; +export * from './api'; diff --git a/plugins/catalog-import/src/plugin.test.ts b/plugins/catalog-import/src/plugin.test.ts index 2f5964946c..7ebe194b36 100644 --- a/plugins/catalog-import/src/plugin.test.ts +++ b/plugins/catalog-import/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { catalogImportPlugin } from './plugin'; describe('catalog-import', () => { 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 3ce4918553..18b3124b3b 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -15,28 +15,59 @@ */ import { + identityApiRef, + configApiRef, createApiFactory, createPlugin, + createRoutableExtension, createRouteRef, discoveryApiRef, githubAuthApiRef, } from '@backstage/core'; -import { catalogImportApiRef } from './api/CatalogImportApi'; -import { CatalogImportClient } from './api/CatalogImportClient'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogImportApiRef, CatalogImportClient } from './api'; export const rootRouteRef = createRouteRef({ path: '', title: 'catalog-import', }); -export const plugin = createPlugin({ +export const catalogImportPlugin = createPlugin({ id: 'catalog-import', apis: [ createApiFactory({ api: catalogImportApiRef, - deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef }, - factory: ({ discoveryApi, githubAuthApi }) => - new CatalogImportClient({ discoveryApi, githubAuthApi }), + deps: { + discoveryApi: discoveryApiRef, + githubAuthApi: githubAuthApiRef, + identityApi: identityApiRef, + configApi: configApiRef, + catalogApi: catalogApiRef, + }, + factory: ({ + discoveryApi, + githubAuthApi, + identityApi, + configApi, + catalogApi, + }) => + new CatalogImportClient({ + discoveryApi, + githubAuthApi, + configApi, + identityApi, + catalogApi, + }), }), ], + 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/types.ts b/plugins/catalog-import/src/types.ts similarity index 100% rename from plugins/catalog-import/src/util/types.ts rename to plugins/catalog-import/src/types.ts diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts deleted file mode 100644 index a18edc2777..0000000000 --- a/plugins/catalog-import/src/util/useGithubRepos.ts +++ /dev/null @@ -1,57 +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 YAML from 'yaml'; -import { useApi } from '@backstage/core'; -import { catalogImportApiRef } from '../api/CatalogImportApi'; -import { ConfigSpec } from '../components/ImportComponentPage'; - -export function useGithubRepos() { - const api = useApi(catalogImportApiRef); - - const submitPrToRepo = async (selectedRepo: ConfigSpec) => { - const [ownerName, repoName] = selectedRepo.location.split('/').slice(-2); - const submitPRResponse = await api - .submitPrToRepo({ - owner: ownerName, - repo: repoName, - fileContent: selectedRepo.config - .map(entity => `---\n${YAML.stringify(entity)}`) - .join('\n'), - }) - .catch(e => { - throw new Error(`Failed to submit PR to repo:\n${e.message}`); - }); - - await api - .createRepositoryLocation({ - location: submitPRResponse.location, - }) - .catch(e => { - throw new Error(`Failed to create repository location:\n${e.message}`); - }); - - return submitPRResponse; - }; - - return { - submitPrToRepo, - generateEntityDefinitions: (repo: string) => - api.generateEntityDefinitions({ repo }), - addLocation: (location: string) => - api.createRepositoryLocation({ location }), - }; -} 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..85035880eb --- /dev/null +++ b/plugins/catalog-react/CHANGELOG.md @@ -0,0 +1,87 @@ +# @backstage/plugin-catalog-react + +## 0.1.0 + +### Minor Changes + +- d0760ecdf: Moved common useStarredEntities hook to plugin-catalog-react + +### Patch Changes + +- 88f1f1b60: Truncate and show ellipsis with tooltip if content of + `createMetadataDescriptionColumn` is too wide. +- 9615e68fb: Forward link styling of `EntityRefLink` and `EnriryRefLinks` into the underling + `Link`. +- 5c2e2863f: Added the proper type parameters to entityRouteRef. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [1407b34c6] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/catalog-model@0.7.2 + +## 0.0.4 + +### Patch Changes + +- d34d26125: Limit the props that are forwarded to the `Link` component in the `EntityRefLink`. +- 0af242b6d: Introduce new cards to `@backstage/plugin-catalog` that can be added to entity pages: + + - `EntityHasSystemsCard` to display systems of a domain. + - `EntityHasComponentsCard` to display components of a system. + - `EntityHasSubcomponentsCard` to display subcomponents of a subcomponent. + - In addition, `EntityHasApisCard` to display APIs of a system is added to `@backstage/plugin-api-docs`. + + `@backstage/plugin-catalog-react` now provides an `EntityTable` to build own cards for entities. + The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, + `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. + +- 10a0124e0: Expose `useRelatedEntities` from `@backstage/plugin-catalog-react` to retrieve + entities references via relations from the API. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + +## 0.0.3 + +### Patch Changes + +- 19d354c78: Make `EntityRefLink` a `React.forwardRef` in order to use it as root component in other components like `ListItem`. +- Updated dependencies [6ed2b47d6] +- Updated dependencies [72b96e880] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/core@0.6.1 + +## 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..11f5abe074 --- /dev/null +++ b/plugins/catalog-react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-catalog-react", + "version": "0.1.0", + "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.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@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.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@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/techdocs-backend/src/techdocs/stages/publish/types.ts b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx similarity index 61% rename from plugins/techdocs-backend/src/techdocs/stages/publish/types.ts rename to plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx index ca85d9f56e..861fe3d9e7 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/types.ts +++ b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx @@ -14,19 +14,22 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; +import React, { ReactNode } from 'react'; +import { EntityContext } from '../../hooks'; -/** - * Publisher is in charge of taking a folder created by - * the builder, and pushing it to storage - */ -export type PublisherBase = { - /** - * - * @param opts object containing the entity from the service - * catalog, and the directory that has been generated - */ - publish(opts: { - entity: Entity; - directory: string; - }): Promise<{ remoteUrl: string }> | { remoteUrl: string }; +type EntityProviderProps = { + entity: Entity; + children: ReactNode; }; + +export const EntityProvider = ({ entity, children }: EntityProviderProps) => ( + + {children} + +); 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..73e3824d98 --- /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, LinkProps } from '@backstage/core'; +import React, { forwardRef } from 'react'; +import { generatePath } from 'react-router'; +import { entityRoute } from '../../routes'; +import { formatEntityRefTitle } from './format'; + +export type EntityRefLinkProps = { + entityRef: Entity | EntityName; + defaultKind?: string; + children?: React.ReactNode; +} & Omit; + +export const EntityRefLink = forwardRef( + (props, ref) => { + const { entityRef, defaultKind, children, ...linkProps } = props; + + 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..52ed87c80b --- /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 { LinkProps } from '@backstage/core'; +import React from 'react'; +import { EntityRefLink } from './EntityRefLink'; + +export type EntityRefLinksProps = { + entityRefs: (Entity | EntityName)[]; + defaultKind?: string; +} & Omit; + +export const EntityRefLinks = ({ + entityRefs, + defaultKind, + ...linkProps +}: EntityRefLinksProps) => ( + <> + {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/EntityTable/EntityTable.test.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx new file mode 100644 index 0000000000..33bbbd007d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; + +describe('', () => { + it('shows empty table', async () => { + const { getByText } = await renderInTestApp( + EMPTY} + columns={[]} + />, + ); + + expect(getByText('Entities')).toBeInTheDocument(); + expect(getByText('EMPTY')).toBeInTheDocument(); + }); + + it('shows entities', async () => { + const entities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-entity', + }, + spec: {}, + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={[ + { + title: 'Name', + field: 'metadata.name', + }, + ]} + />, + ); + + await waitFor(() => { + expect(getByText('my-entity')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx new file mode 100644 index 0000000000..afcaaea305 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Table, TableColumn } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import React, { ReactNode } from 'react'; +import * as columnFactories from './columns'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +type Props = { + title: string; + variant?: 'gridItem'; + entities: T[]; + emptyContent?: ReactNode; + columns: TableColumn[]; +}; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export function EntityTable({ + entities, + title, + emptyContent, + variant = 'gridItem', + columns, +}: Props) { + const classes = useStyles(); + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + return ( + + columns={columns} + title={title} + style={tableStyle} + emptyContent={ + emptyContent &&
{emptyContent}
+ } + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + data={entities} + /> + ); +} + +EntityTable.columns = columnFactories; + +EntityTable.systemEntityColumns = systemEntityColumns; + +EntityTable.componentEntityColumns = componentEntityColumns; diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx new file mode 100644 index 0000000000..7f5417df4e --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; +import { OverflowTooltip, TableColumn } from '@backstage/core'; +import React from 'react'; +import { getEntityRelations } from '../../utils'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '../EntityRefLink'; + +export function createEntityRefColumn({ + defaultKind, +}: { + defaultKind?: string; +}): TableColumn { + function formatContent(entity: T): string { + return formatEntityRefTitle(entity, { + defaultKind, + }); + } + + return { + title: 'Name', + highlight: true, + customFilterAndSearch(filter, entity) { + // TODO: We could implement this more efficiently, like searching over + // each field that is displayed individually (kind, namespace, name). + // but that migth confuse the user as it will behave different than a + // simple text search. + // Another alternative would be to cache the values. But writing them + // into the entity feels bad too. + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + // TODO: We could implement this more efficiently by comparing field by field. + // This has similar issues as above. + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => ( + + ), + }; +} + +export function createEntityRelationColumn({ + title, + relation, + defaultKind, + filter: entityFilter, +}: { + title: string; + relation: string; + defaultKind?: string; + filter?: { kind: string }; +}): TableColumn { + function getRelations(entity: T): EntityName[] { + return getEntityRelations(entity, relation, entityFilter); + } + + function formatContent(entity: T): string { + return getRelations(entity) + .map(r => formatEntityRefTitle(r, { defaultKind })) + .join(', '); + } + + return { + title, + customFilterAndSearch(filter, entity) { + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => { + return ( + + ); + }, + }; +} + +export function createOwnerColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Owner', + relation: RELATION_OWNED_BY, + defaultKind: 'group', + }); +} + +export function createDomainColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Domain', + relation: RELATION_PART_OF, + defaultKind: 'domain', + filter: { + kind: 'domain', + }, + }); +} + +export function createSystemColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'System', + relation: RELATION_PART_OF, + defaultKind: 'system', + filter: { + kind: 'system', + }, + }); +} + +export function createMetadataDescriptionColumn< + T extends Entity +>(): TableColumn { + return { + title: 'Description', + field: 'metadata.description', + render: entity => ( + + ), + width: 'auto', + }; +} + +export function createSpecLifecycleColumn(): TableColumn { + return { + title: 'Lifecycle', + field: 'spec.lifecycle', + }; +} + +export function createSpecTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + }; +} diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/catalog-react/src/components/EntityTable/index.ts similarity index 92% rename from plugins/kubernetes/src/components/Ingresses/index.ts rename to plugins/catalog-react/src/components/EntityTable/index.ts index ecc67480db..8a01b9baae 100644 --- a/plugins/kubernetes/src/components/Ingresses/index.ts +++ b/plugins/catalog-react/src/components/EntityTable/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Ingresses } from './Ingresses'; +export { EntityTable } from './EntityTable'; diff --git a/plugins/catalog-react/src/components/EntityTable/presets.test.tsx b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx new file mode 100644 index 0000000000..0f87c4132f --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + RELATION_OWNED_BY, + RELATION_PART_OF, + SystemEntity, +} from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +describe('systemEntityColumns', () => { + it('shows systems', async () => { + const entities: SystemEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'Domain', + name: 'my-domain', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={systemEntityColumns} + />, + ); + + await waitFor(() => { + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('my-namespace/my-domain')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText(/Some/)).toBeInTheDocument(); + }); + }); +}); + +describe('componentEntityColumns', () => { + it('shows components', async () => { + const entities: ComponentEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'my-system', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={componentEntityColumns} + />, + ); + + await waitFor(() => { + expect(getByText('my-namespace/my-component')).toBeInTheDocument(); + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText(/Some/)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/presets.tsx b/plugins/catalog-react/src/components/EntityTable/presets.tsx new file mode 100644 index 0000000000..7ce6b75532 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity, SystemEntity } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { + createDomainColumn, + createEntityRefColumn, + createMetadataDescriptionColumn, + createOwnerColumn, + createSpecLifecycleColumn, + createSpecTypeColumn, + createSystemColumn, +} from './columns'; + +export const systemEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'system' }), + createDomainColumn(), + createOwnerColumn(), + createMetadataDescriptionColumn(), +]; + +export const componentEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'component' }), + createSystemColumn(), + createOwnerColumn(), + createSpecTypeColumn(), + createSpecLifecycleColumn(), + createMetadataDescriptionColumn(), +]; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts new file mode 100644 index 0000000000..5181b8f0ea --- /dev/null +++ b/plugins/catalog-react/src/components/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './EntityProvider'; +export * from './EntityRefLink'; +export * from './EntityTable'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts new file mode 100644 index 0000000000..d964c22a3b --- /dev/null +++ b/plugins/catalog-react/src/hooks/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 { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; +export { useEntityCompoundName } from './useEntityCompoundName'; +export { useRelatedEntities } from './useRelatedEntities'; +export { useStarredEntities } from './useStarredEntities'; diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog-react/src/hooks/useEntity.ts similarity index 89% rename from plugins/catalog/src/hooks/useEntity.ts rename to plugins/catalog-react/src/hooks/useEntity.ts index 29ca63904d..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; @@ -55,7 +55,7 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { }; /** - * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. + * Grab Entity from the context and its current loading state. */ export const useEntity = () => { const { entity, loading, error } = useContext<{ 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/hooks/useRelatedEntities.ts b/plugins/catalog-react/src/hooks/useRelatedEntities.ts new file mode 100644 index 0000000000..baf6cf5fc7 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api'; + +export function useRelatedEntities( + entity: Entity, + { type, kind }: { type?: string; kind?: string }, +): { + entities: Entity[] | undefined; + loading: boolean; + error: Error | undefined; +} { + const catalogApi = useApi(catalogApiRef); + const { loading, value: entities, error } = useAsync(async () => { + const relations = + entity.relations && + entity.relations.filter( + r => + (!type || r.type.toLowerCase() === type.toLowerCase()) && + (!kind || r.target.kind.toLowerCase() === kind.toLowerCase()), + ); + + if (!relations) { + return []; + } + + // TODO: This code could be more efficient if there was an endpoint in the + // backend that either returns the relations of entity (filtered by type) + // or if there is a way to perform a batch request by entity name. However, + // such an implementation would probably be better placed in the graphql API. + const results = await Promise.all( + relations?.map(r => catalogApi.getEntityByName(r.target)), + ); + // Skip entities that where not found, for example if a relation references + // an entity that doesn't exist. + return results.filter(e => e) as Entity[]; + }, [entity, type]); + + return { + entities, + loading, + error, + }; +} diff --git a/plugins/catalog/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx similarity index 94% rename from plugins/catalog/src/hooks/useStarredEntities.test.tsx rename to plugins/catalog-react/src/hooks/useStarredEntities.test.tsx index 345bc9f04f..f1a8df1f32 100644 --- a/plugins/catalog/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useStarredEntities } from './useStarredEntities'; import { @@ -47,7 +47,7 @@ describe('useStarredEntities', () => { }, }; - const wrapper: React.FC<{}> = ({ children }) => { + const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( {children} @@ -65,7 +65,7 @@ describe('useStarredEntities', () => { expect(result.current.starredEntities.size).toBe(0); }); - it('should return a set with the current items when there is items in storage', async () => { + it('should return a set with the current items when there are items in storage', async () => { const expectedIds = ['i', 'am', 'some', 'test', 'ids']; const store = mockStorage?.forBucket('settings'); await store?.set('starredEntities', expectedIds); @@ -95,7 +95,7 @@ describe('useStarredEntities', () => { expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); }); - it('should write new entries to the local store when adding a togglging entity', async () => { + it('should write new entries to the local store when adding a toggling entity', async () => { const { result } = renderHook(() => useStarredEntities(), { wrapper }); act(() => { diff --git a/plugins/catalog/src/hooks/useStarredEntities.ts b/plugins/catalog-react/src/hooks/useStarredEntities.ts similarity index 100% rename from plugins/catalog/src/hooks/useStarredEntities.ts rename to plugins/catalog-react/src/hooks/useStarredEntities.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-react/src/routes.ts b/plugins/catalog-react/src/routes.ts new file mode 100644 index 0000000000..7470b8030e --- /dev/null +++ b/plugins/catalog-react/src/routes.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 { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +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: '', + title: 'Catalog', +}); +export const catalogRouteRef = rootRoute; + +export const entityRoute = createRouteRef({ + icon: NoIcon, + path: ':namespace/:kind/:name/*', + title: 'Entity', + params: ['namespace', 'kind', 'name'], +}); +export const entityRouteRef = entityRoute; + +// Utility function to get suitable route params for entityRoute, given an +// entity instance +export function entityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + } as const; +} 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 7630bdb077..129bb98346 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,333 @@ # @backstage/plugin-catalog +## 0.4.0 + +### Minor Changes + +- a5f42cf66: The Scaffolder and Catalog plugins have been migrated to partially require use of the [new composability API](https://backstage.io/docs/plugins/composability). The Scaffolder used to register its pages using the deprecated route registration plugin API, but those registrations have been removed. This means you now need to add the Scaffolder plugin page to the app directly. + + The page is imported from the Scaffolder plugin and added to the `` component: + + ```tsx + } /> + ``` + + The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. + + To use the new extension components, replace existing usage of the `CatalogRouter` with the following: + + ```tsx + } /> + }> + + + ``` + + And to bind the external route from the catalog plugin to the scaffolder template index page, make sure you have the appropriate imports and add the following to the `createApp` call: + + ```ts + import { catalogPlugin } from '@backstage/plugin-catalog'; + import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + + const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, + }); + ``` + +- d0760ecdf: Moved common useStarredEntities hook to plugin-catalog-react + +### Patch Changes + +- d6593abe6: Remove domain column from `HasSystemsCard` and system from `HasComponentsCard`, + `HasSubcomponentsCard`, and `HasApisCard`. +- bad21a085: Implement annotations for customising Entity URLs in the Catalog pages. +- 437bac549: Make the description column in the catalog table and api-docs table use up as + much space as possible before hiding overflowing text. +- 5469a9761: Changes made in CatalogTable and ApiExplorerTable for using the OverflowTooltip component for truncating large description and showing tooltip on hover-over. +- 60d1bc3e7: Fix Japanese Good Morning +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.2 + +### Patch Changes + +- 32a950409: Hide the kind of the owner if it's the default kind for the `ownedBy` + relationship (group). +- f10950bd2: Minor refactoring of BackstageApp.getSystemIcons to support custom registered + icons. Custom Icons can be added using: + + ```tsx + import AlarmIcon from '@material-ui/icons/Alarm'; + import MyPersonIcon from './MyPerson'; + + const app = createApp({ + icons: { + user: MyPersonIcon // override system icon + alert: AlarmIcon, // Custom icon + }, + }); + ``` + +- 914c89b13: Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder. +- 0af242b6d: Introduce new cards to `@backstage/plugin-catalog` that can be added to entity pages: + + - `EntityHasSystemsCard` to display systems of a domain. + - `EntityHasComponentsCard` to display components of a system. + - `EntityHasSubcomponentsCard` to display subcomponents of a subcomponent. + - In addition, `EntityHasApisCard` to display APIs of a system is added to `@backstage/plugin-api-docs`. + + `@backstage/plugin-catalog-react` now provides an `EntityTable` to build own cards for entities. + The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, + `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- 53b69236d: Migrate about card to new composability API, exporting the entity cards as `EntityAboutCard`. +- Updated dependencies [6c4a76c59] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/plugin-scaffolder@0.5.1 + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.1 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- ca559171b: bug fix: 3310 fixes reloading entities with the default owned filter +- f5e564cd6: Improve display of error messages +- 1df75733e: Adds an `EntityLinksCard` component to display `entity.metadata.links` on entity pages. The new component is a companion for the new [Entity Links](https://backstage.io/docs/features/software-catalog/descriptor-format#links-optional) catalog model addition. + + Here is an example usage within an `EntityPage.tsx`. + + ```tsx + // in packages/app/src/components/catalog/EntityPage.tsx + const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + // or ... + + + + ); + ``` + +- e5da858d7: Removed unused functions and the moment library. #4278 +- 9230d07e7: Fix whitespace around variable in unregister error dialog box +- Updated dependencies [6ed2b47d6] +- Updated dependencies [72b96e880] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 7e0b8cac5: Add `CatalogIndexPage` and `CatalogEntityPage`, two new extensions that replace the existing `Router` component. + + Add `EntityLayout` to replace `EntityPageLayout`, using children instead of an element property, and allowing for collection of all `RouteRef` mount points used within tabs. + + Add `EntitySwitch` to be used to select components based on entity data, along with accompanying `isKind`, `isNamespace`, and `isComponentType` filters. + +- 87c0c53c2: Add new `EntityProvider` component, which can be used to provide an entity for the `useEntity` hook. +- Updated dependencies [a08c32ced] +- Updated dependencies [359f9d2d8] + - @backstage/core@0.4.3 + - @backstage/plugin-techdocs@0.5.2 + +## 0.2.8 + +### Patch Changes + +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [19554f6d6] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/plugin-scaffolder@0.3.5 + - @backstage/core@0.4.2 + - @backstage/plugin-techdocs@0.5.1 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [dae4f3983] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/plugin-techdocs@0.5.0 + - @backstage/core@0.4.1 + - @backstage/catalog-client@0.3.4 + - @backstage/plugin-scaffolder@0.3.4 + +## 0.2.6 + +### Patch Changes + +- 6011b7d3e: Added pagerduty plugin to example app +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [87a33d2fe] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/plugin-techdocs@0.4.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-scaffolder@0.3.3 + - @backstage/catalog-client@0.3.3 + ## 0.2.5 ### 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 6887f71b3b..06fe95cab6 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.5", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/catalog" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,19 +30,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.2", - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-scaffolder": "^0.3.2", - "@backstage/plugin-techdocs": "^0.3.1", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", "@types/react": "^16.9", "classnames": "^2.2.6", - "git-url-parse": "^11.4.0", - "moment": "^2.26.0", + "git-url-parse": "^11.4.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-helmet": "6.1.0", @@ -43,12 +50,12 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", diff --git a/plugins/catalog/src/CatalogClientWrapper.test.ts b/plugins/catalog/src/CatalogClientWrapper.test.ts new file mode 100644 index 0000000000..87dcd8f74b --- /dev/null +++ b/plugins/catalog/src/CatalogClientWrapper.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { CatalogClientWrapper } from './CatalogClientWrapper'; + +jest.mock('@backstage/catalog-client'); +const MockedCatalogClient = CatalogClient as jest.Mock; + +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discoveryApi: DiscoveryApi = { + async getBaseUrl(_pluginId) { + return mockBaseUrl; + }, +}; +const identityApi: IdentityApi = { + getUserId() { + return 'jane-fonda'; + }, + getProfile() { + return { email: 'jane-fonda@spotify.com' }; + }, + async getIdToken() { + return Promise.resolve('fake-id-token'); + }, + async signOut() { + return Promise.resolve(); + }, +}; +const guestIdentityApi: IdentityApi = { + getUserId() { + return 'guest'; + }, + getProfile() { + return {}; + }, + async getIdToken() { + return Promise.resolve(undefined); + }, + async signOut() { + return Promise.resolve(); + }, +}; + +describe('CatalogClientWrapper', () => { + beforeEach(() => { + MockedCatalogClient.mockClear(); + }); + + describe('getEntities', () => { + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getEntities(); + const getEntities = MockedCatalogClient.mock.instances[0].getEntities; + expect(getEntities).toHaveBeenCalledWith(undefined, { + token: 'fake-id-token', + }); + expect(getEntities).toHaveBeenCalledTimes(1); + }); + }); + + describe('getLocationById', () => { + it('omits authorization token when guest', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi: guestIdentityApi, + }); + await client.getLocationById('42'); + const getLocationById = + MockedCatalogClient.mock.instances[0].getLocationById; + expect(getLocationById).toHaveBeenCalledWith('42', {}); + expect(getLocationById).toHaveBeenCalledTimes(1); + }); + }); + + describe('getEntityByName', () => { + const name = { + kind: 'kind', + namespace: 'namespace', + name: 'name', + }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getEntityByName(name); + const getEntityByName = + MockedCatalogClient.mock.instances[0].getEntityByName; + expect(getEntityByName).toHaveBeenCalledWith(name, { + token: 'fake-id-token', + }); + expect(getEntityByName).toHaveBeenCalledTimes(1); + }); + }); + + describe('addLocation', () => { + const location = { target: 'target' }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.addLocation(location); + const addLocation = MockedCatalogClient.mock.instances[0].addLocation; + expect(addLocation).toHaveBeenCalledWith(location, { + token: 'fake-id-token', + }); + expect(addLocation).toHaveBeenCalledTimes(1); + }); + }); + + describe('getLocationByEntity', () => { + const entity = { + apiVersion: 'apiVersion', + kind: 'kind', + metadata: { + name: 'name', + }, + }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getLocationByEntity(entity); + const getLocationByEntity = + MockedCatalogClient.mock.instances[0].getLocationByEntity; + expect(getLocationByEntity).toHaveBeenCalledWith(entity, { + token: 'fake-id-token', + }); + expect(getLocationByEntity).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeEntityByUid', () => { + it('injects authorization token', async () => { + const uid = 'uid'; + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.removeEntityByUid(uid); + const removeEntityByUid = + MockedCatalogClient.mock.instances[0].removeEntityByUid; + expect(removeEntityByUid).toHaveBeenCalledWith(uid, { + token: 'fake-id-token', + }); + expect(removeEntityByUid).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts new file mode 100644 index 0000000000..42e9d872d4 --- /dev/null +++ b/plugins/catalog/src/CatalogClientWrapper.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 { Entity, EntityName, Location } from '@backstage/catalog-model'; +import { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, + CatalogClient, +} from '@backstage/catalog-client'; +import { IdentityApi } from '@backstage/core'; + +type CatalogRequestOptions = { + token?: string; +}; + +/** + * CatalogClient wrapper that injects identity token for all requests + */ +export class CatalogClientWrapper implements CatalogApi { + private readonly identityApi: IdentityApi; + private readonly client: CatalogClient; + + constructor(options: { client: CatalogClient; identityApi: IdentityApi }) { + this.client = options.client; + this.identityApi = options.identityApi; + } + + async getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getLocationById(id, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getEntities( + request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, + ): Promise> { + return await this.client.getEntities(request, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getEntityByName( + compoundName: EntityName, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getEntityByName(compoundName, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async addLocation( + request: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.addLocation(request, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getLocationByEntity(entity, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.removeEntityByUid(uid, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } +} diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 3038fc3c5c..449d3c1fc9 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -14,8 +14,13 @@ * limitations under the License. */ -import React from 'react'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + SOURCE_LOCATION_ANNOTATION, + EDIT_URL_ANNOTATION, +} from '@backstage/catalog-model'; import { render } from '@testing-library/react'; +import React from 'react'; import { AboutCard } from './AboutCard'; describe(' GitHub', () => { @@ -36,7 +41,11 @@ describe(' GitHub', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', @@ -67,7 +76,11 @@ describe(' GitLab', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', @@ -98,7 +111,11 @@ describe(' BitBucket', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', @@ -110,3 +127,41 @@ describe(' BitBucket', () => { ); }); }); + +describe(' custom links', () => { + it('renders info and "view source" link', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/managed-by-location': + 'bitbucket:https://bitbucket.org/backstage/backstage/src/master/software.yaml', + [EDIT_URL_ANNOTATION]: 'https://another.place', + [SOURCE_LOCATION_ANNOTATION]: + 'url:https://another.place/backstage.git', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render( + + + , + ); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText('View Source').closest('a')).toHaveAttribute( + 'href', + 'https://another.place/backstage.git', + ); + expect(getByText('View Source').closest('a')).toHaveAttribute( + 'edithref', + 'https://another.place', + ); + }); +}); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 49653fe981..64564f5ec4 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -16,57 +16,31 @@ import { Entity, + LocationSpec, ENTITY_DEFAULT_NAMESPACE, - RELATION_OWNED_BY, + SOURCE_LOCATION_ANNOTATION, RELATION_PROVIDES_API, - serializeEntityRef, } from '@backstage/catalog-model'; +import { HeaderIconLinkRow } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, CardContent, CardHeader, - Chip, Divider, - Grid, IconButton, makeStyles, - Typography, } from '@material-ui/core'; -import ExtensionIcon from '@material-ui/icons/Extension'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; +import ExtensionIcon from '@material-ui/icons/Extension'; import GitHubIcon from '@material-ui/icons/GitHub'; import React from 'react'; -import { IconLinkVertical } from './IconLinkVertical'; -import { findLocationForEntityMeta } from '../../data/utils'; -import { createEditLink, determineUrlType } from '../createEditLink'; +import { findLocationForEntityMeta, parseLocation } from '../../data/utils'; +import { findEditUrl, determineUrlType } from '../actions'; +import { AboutContent } from './AboutContent'; -const useStyles = makeStyles(theme => ({ - links: { - margin: theme.spacing(2, 0), - display: 'grid', - gridAutoFlow: 'column', - gridAutoColumns: 'min-content', - gridGap: theme.spacing(3), - }, - label: { - color: theme.palette.text.secondary, - textTransform: 'uppercase', - fontSize: '10px', - fontWeight: 'bold', - letterSpacing: 0.5, - overflow: 'hidden', - whiteSpace: 'nowrap', - }, - value: { - fontWeight: 'bold', - overflow: 'hidden', - lineHeight: '24px', - wordBreak: 'break-word', - }, - description: { - wordBreak: 'break-word', - }, +const useStyles = makeStyles({ gridItemCard: { display: 'flex', flexDirection: 'column', @@ -76,7 +50,7 @@ const useStyles = makeStyles(theme => ({ gridItemCardContent: { flex: 1, }, -})); +}); const iconMap: Record = { github: , @@ -88,32 +62,68 @@ type CodeLinkInfo = { href?: string; }; +function getSourceLocationForEntity( + entity: Entity, + location?: LocationSpec, +): LocationSpec | undefined { + const annotation = entity.metadata?.annotations?.[SOURCE_LOCATION_ANNOTATION]; + const parsed = annotation && parseLocation(annotation); + + return parsed || location; +} + function getCodeLinkInfo(entity: Entity): CodeLinkInfo { const location = findLocationForEntityMeta(entity?.metadata); + const editUrl = findEditUrl(entity); + let sourceLocation = getSourceLocationForEntity(entity, location); + if (location) { + sourceLocation = sourceLocation || location; const type = - location.type === 'url' - ? determineUrlType(location.target) - : location.type; + sourceLocation.type === 'url' + ? determineUrlType(sourceLocation.target) + : sourceLocation.type; return { + edithref: editUrl, icon: iconMap[type], - edithref: createEditLink(location), - href: location.target, + href: sourceLocation.target, }; } - return {}; + + return { edithref: editUrl, href: sourceLocation?.target }; } type AboutCardProps = { - entity: Entity; - variant?: string; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: 'gridItem'; }; -export function AboutCard({ entity, variant }: AboutCardProps) { +export function AboutCard({ variant }: AboutCardProps) { const classes = useStyles(); + const { entity } = useEntity(); const codeLink = getCodeLinkInfo(entity); // TODO: Also support RELATION_CONSUMES_API here const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API); + const viewInSource = { + label: 'View Source', + ...codeLink, + }; + const viewInTechDocs = { + label: 'View TechDocs', + disabled: !entity.metadata.annotations?.['backstage.io/techdocs-ref'], + icon: , + href: `/docs/${entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${ + entity.kind + }/${entity.metadata.name}`, + }; + const viewApi = { + title: hasApis ? '' : 'No APIs available', + label: 'View API', + disabled: !hasApis, + icon: , + href: 'api', + }; return ( @@ -131,117 +141,15 @@ export function AboutCard({ entity, variant }: AboutCardProps) { } subheader={ - + } /> - - - - {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 }} - /> - - - - {(entity?.metadata?.tags || []).map(t => ( - - ))} - - + ); } - -function AboutField({ - label, - value, - gridSizes, - children, -}: { - label: string; - value?: string; - gridSizes?: Record; - children?: React.ReactNode; -}) { - const classes = useStyles(); - - // Content is either children or a string prop `value` - const content = React.Children.count(children) ? ( - children - ) : ( - - {value || `unknown`} - - ); - return ( - - - {label} - - {content} - - ); -} diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx new file mode 100644 index 0000000000..57ea894570 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + RELATION_OWNED_BY, + 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: { + wordBreak: 'break-word', + }, +}); + +type Props = { + entity: Entity; +}; + +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 ( + + + + {entity?.metadata?.description || 'No description'} + + + + + + {isSystem && ( + + + + )} + {!isSystem && !isDomain && ( + + + + )} + {isComponent && partOfComponentRelations.length > 0 && ( + + + + )} + {!isSystem && !isDomain && ( + + )} + {!isSystem && !isDomain && !isResource && ( + + )} + + {(entity?.metadata?.tags || []).map(t => ( + + ))} + + + ); +}; diff --git a/plugins/catalog/src/components/AboutCard/AboutField.tsx b/plugins/catalog/src/components/AboutCard/AboutField.tsx new file mode 100644 index 0000000000..fdcb3ace1b --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutField.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles, Typography, Grid } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + value: { + fontWeight: 'bold', + overflow: 'hidden', + lineHeight: '24px', + wordBreak: 'break-word', + }, + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '10px', + fontWeight: 'bold', + letterSpacing: 0.5, + overflow: 'hidden', + whiteSpace: 'nowrap', + }, +})); + +type Props = { + label: string; + value?: string; + gridSizes?: Record; + children?: React.ReactNode; +}; + +export const AboutField = ({ label, value, gridSizes, children }: Props) => { + const classes = useStyles(); + + // Content is either children or a string prop `value` + const content = React.Children.count(children) ? ( + children + ) : ( + + {value || `unknown`} + + ); + return ( + + + {label} + + {content} + + ); +}; diff --git a/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx new file mode 100644 index 0000000000..bf5fb1d9d6 --- /dev/null +++ b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.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 React from 'react'; +import { Outlet } from 'react-router'; +import { EntityLoaderProvider } from '../EntityLoaderProvider'; + +export const CatalogEntityPage = () => { + return ( + + + + ); +}; diff --git a/plugins/catalog/src/components/CatalogEntityPage/index.ts b/plugins/catalog/src/components/CatalogEntityPage/index.ts new file mode 100644 index 0000000000..627ca80e48 --- /dev/null +++ b/plugins/catalog/src/components/CatalogEntityPage/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 { CatalogEntityPage } from './CatalogEntityPage'; diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx index e78cedbe2d..efacfa4320 100644 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -15,10 +15,10 @@ */ import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CircularProgress, useTheme } from '@material-ui/core'; import React from 'react'; import { useAsync } from 'react-use'; -import { catalogApiRef } from '../../plugin'; export const AllServicesCount = () => { 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/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 90a1e15ad0..6de4c318b5 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -80,6 +80,14 @@ type Props = { onChange?: OnChangeCallback; }; +/** + * Sidebar filter type and human readable label for it. owned/starred/all + */ +export type CatalogFilterType = { + id: string; + label: string; +}; + /** * The main filter group in the sidebar, toggling owned/starred/all. */ diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 28fb1b08c7..accfe6eda4 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -28,11 +28,12 @@ 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 { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; +import { createComponentRouteRef } from '../../routes'; import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { @@ -116,6 +117,11 @@ describe('CatalogPage', () => { > {children},
, + { + mountedRoutes: { + '/create': createComponentRouteRef, + }, + }, ), ); @@ -128,4 +134,24 @@ describe('CatalogPage', () => { fireEvent.click(getByText(/All/)); expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); + // this test is for fixing the bug after favoriting an entity, the matching entities defaulting + // to "owned" filter and not based on the selected filter + it('should render the correct entities filtered on the selectedfilter', async () => { + const { findByText, findAllByTitle, getByText } = renderWrapped( + , + ); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + expect(await findByText(/Starred/)).toBeInTheDocument(); + fireEvent.click(getByText(/Starred/)); + expect(await findByText(/Starred \(0\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); + + const starredIcons = await findAllByTitle('Add to favorites'); + fireEvent.click(starredIcons[0]); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); + + fireEvent.click(getByText(/Starred/)); + waitFor(() => expect(findByText(/Starred \(1\)/)).toBeInTheDocument()); + }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 077e1f092f..9190ae703e 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -21,24 +21,31 @@ import { errorApiRef, SupportButton, useApi, + useRouteRef, } from '@backstage/core'; -import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; +import { + catalogApiRef, + isOwnerOf, + useStarredEntities, +} from '@backstage/plugin-catalog-react'; + import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; 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 { createComponentRouteRef } from '../../routes'; +import { + ButtonGroup, + CatalogFilter, + CatalogFilterType, +} 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: { @@ -67,9 +74,11 @@ const CatalogPageContents = () => { const errorApi = useApi(errorApiRef); const { isStarredEntity } = useStarredEntities(); const [selectedTab, setSelectedTab] = useState(); - const [selectedSidebarItem, setSelectedSidebarItem] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState< + CatalogFilterType + >(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; - + const createComponentLink = useRouteRef(createComponentRouteRef); const addMockData = useCallback(async () => { try { const promises: Promise[] = []; @@ -157,16 +166,17 @@ const CatalogPageContents = () => { onChange={({ label }) => setSelectedTab(label)} /> - - + {createComponentLink && ( + + )} {showAddExampleEntities && ( + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx new file mode 100644 index 0000000000..8caf37e70d --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.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 { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { IconLink } from './IconLink'; + +describe('IconLink', () => { + it('should render an icon link', () => { + const rendered = render( + + + , + ); + + expect(rendered.queryByText('I am Link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx new file mode 100644 index 0000000000..32ea085889 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.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 { Link, IconComponent } from '@backstage/core'; +import { Grid, makeStyles, Typography } from '@material-ui/core'; +import LanguageIcon from '@material-ui/icons/Language'; +import React from 'react'; + +const useStyles = makeStyles({ + svgIcon: { + display: 'inline-block', + '& svg': { + display: 'inline-block', + fontSize: 'inherit', + verticalAlign: 'baseline', + }, + }, +}); + +export const IconLink = ({ + href, + text, + Icon, +}: { + href: string; + text?: string; + Icon?: IconComponent; +}) => { + const classes = useStyles(); + + return ( + + + + {Icon ? : } + + + + + {text || href} + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx new file mode 100644 index 0000000000..9ef77a4bbf --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IconComponent } from '@backstage/core'; +import { GridList, GridListTile } from '@material-ui/core'; +import React from 'react'; +import { IconLink } from './IconLink'; +import { ColumnBreakpoints } from './types'; +import { useDynamicColumns } from './useDynamicColumns'; + +export type LinksGridListItem = { + href: string; + text?: string; + Icon?: IconComponent; +}; + +type Props = { + items: LinksGridListItem[]; + cols?: ColumnBreakpoints | number; +}; + +export const LinksGridList = ({ items, cols = undefined }: Props) => { + const numOfCols = useDynamicColumns(cols); + + return ( + + {items.map(({ text, href, Icon }, i) => ( + + + + ))} + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/index.ts b/plugins/catalog/src/components/EntityLinksCard/index.ts new file mode 100644 index 0000000000..d893d3a233 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/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 { EntityLinksCard } from './EntityLinksCard'; diff --git a/plugins/catalog/src/components/EntityLinksCard/types.ts b/plugins/catalog/src/components/EntityLinksCard/types.ts new file mode 100644 index 0000000000..d7729fc216 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export type ColumnBreakpoints = Record; diff --git a/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx new file mode 100644 index 0000000000..d2b4168e0b --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.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 { Theme, useMediaQuery } from '@material-ui/core'; +import { Breakpoint, ColumnBreakpoints } from './types'; + +const colDefaults: ColumnBreakpoints = { + xs: 1, + sm: 1, + md: 1, + lg: 2, + xl: 3, +}; + +export function useDynamicColumns( + cols: ColumnBreakpoints | number | undefined, +): number { + const matches: (Breakpoint | null)[] = [ + useMediaQuery((theme: Theme) => theme.breakpoints.up('xl')) ? 'xl' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('lg')) ? 'lg' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('md')) ? 'md' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')) ? 'sm' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('xs')) ? 'xs' : null, + ]; + + let numOfCols = 1; + + if (typeof cols === 'number') { + numOfCols = cols; + } else { + const breakpoint = matches.find(k => k !== null) ?? 'xs'; + numOfCols = cols?.[breakpoint] ?? colDefaults[breakpoint]; + } + + return numOfCols; +} diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx similarity index 82% rename from plugins/catalog/src/components/EntityProvider/EntityProvider.tsx rename to plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx index e328b64d44..97a4263e0d 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + EntityContext, + useEntityFromUrl, +} from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; -import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; -export const EntityProvider = ({ children }: { children: ReactNode }) => { +export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { const { entity, loading, error } = useEntityFromUrl(); return ( diff --git a/plugins/catalog/src/components/EntityLoaderProvider/index.ts b/plugins/catalog/src/components/EntityLoaderProvider/index.ts new file mode 100644 index 0000000000..925c927ec9 --- /dev/null +++ b/plugins/catalog/src/components/EntityLoaderProvider/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 { EntityLoaderProvider } from './EntityLoaderProvider'; diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 8c48d69413..34378d5496 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -13,17 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { Content, Header, HeaderLabel, Page, Progress } from '@backstage/core'; +import { + EntityContext, + EntityRefLinks, + getEntityRelations, + useEntityCompoundName, +} from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { PropsWithChildren, useContext, useState } from 'react'; import { useNavigate } from 'react-router'; -import { EntityContext } from '../../hooks/useEntity'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { useEntityCompoundName } from '../useEntityCompoundName'; import { Tabbed } from './Tabbed'; const EntityPageTitle = ({ @@ -39,12 +47,32 @@ const EntityPageTitle = ({ ); -function headerProps( +const EntityLabels = ({ entity }: { entity: Entity }) => { + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return ( + <> + {ownedByRelations.length > 0 && ( + + } + /> + )} + {entity.spec?.lifecycle && ( + + )} + + ); +}; + +const headerProps = ( kind: string, namespace: string | undefined, name: string, entity: Entity | undefined, -): { headerTitle: string; headerType: string } { +): { headerTitle: string; headerType: string } => { return { headerTitle: `${name}${ namespace && namespace !== ENTITY_DEFAULT_NAMESPACE @@ -60,7 +88,7 @@ function headerProps( return t; })(), }; -} +}; export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { const { kind, namespace, name } = useEntityCompoundName(); @@ -88,16 +116,10 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { pageTitleOverride={headerTitle} type={headerType} > + {/* TODO: Make entity labels configurable for entity kind / type */} {entity && ( <> - - + )} diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx index 096b2fa708..9a4e872d8f 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -39,8 +39,8 @@ const getSelectedIndexOrDefault = ( /** * Compound component, which allows you to define layout - * for EntityPage using Tabs as a subnavigation mechanism - * Constists of 2 parts: Tabbed.Layout and Tabbed.Content. + * for EntityPage using Tabs as a sub-navigation mechanism + * Consists of 2 parts: Tabbed.Layout and Tabbed.Content. * Takes care of: tabs, routes, document titles, spacing around content * * @example diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx new file mode 100644 index 0000000000..20b9aaba4f --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.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 { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { isKind } from './conditions'; +import { EntitySwitch } from './EntitySwitch'; + +describe('EntitySwitch', () => { + it('should switch child when entity switches', () => { + const content = ( + + + + + + ); + + const rendered = render( + + {content} + , + ); + + expect(rendered.queryByText('A')).toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + expect(rendered.queryByText('C')).not.toBeInTheDocument(); + + rendered.rerender( + + {content} + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).toBeInTheDocument(); + expect(rendered.queryByText('C')).not.toBeInTheDocument(); + + rendered.rerender( + + {content} + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + expect(rendered.queryByText('C')).toBeInTheDocument(); + }); + + it('should switch child when filters switch', () => { + const entityContextValue = { + entity: { kind: 'component' } as Entity, + loading: false, + error: undefined, + }; + + const rendered = render( + + + + + + , + ); + + expect(rendered.queryByText('A')).toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + + rendered.rerender( + + + + + + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx new file mode 100644 index 0000000000..a3ede42288 --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.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 { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + Children, + Fragment, + isValidElement, + PropsWithChildren, + ReactNode, + useMemo, +} from 'react'; + +const EntitySwitchCase = (_: { + if?: (entity: Entity) => boolean; + children: ReactNode; +}) => null; + +type SwitchCase = { + if?: (entity: Entity) => boolean; + children: JSX.Element; +}; + +function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] { + return Children.toArray(childrenNode).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSwitchCasesFromChildren(child.props.children); + } + + if (child.type !== EntitySwitchCase) { + throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`); + } + + const { if: condition, children } = child.props; + return [{ if: condition, children }]; + }); +} + +export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => { + const { entity } = useEntity(); + const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [ + children, + ]); + + const matchingCase = switchCases.find(switchCase => + switchCase.if ? switchCase.if(entity) : true, + ); + return matchingCase?.children ?? null; +}; + +EntitySwitch.Case = EntitySwitchCase; diff --git a/plugins/catalog/src/components/EntitySwitch/conditions.ts b/plugins/catalog/src/components/EntitySwitch/conditions.ts new file mode 100644 index 0000000000..8e4da19656 --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/conditions.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 { Entity, ComponentEntity } from '@backstage/catalog-model'; + +function strCmp(a: string | undefined, b: string | undefined): boolean { + return Boolean(a && a?.toLowerCase() === b?.toLowerCase()); +} + +export function isKind(kind: string) { + return (entity: Entity) => strCmp(entity?.kind, kind); +} + +export function isComponentType(type: string) { + return (entity: Entity) => { + if (!strCmp(entity?.kind, 'component')) { + return false; + } + const componentEntity = entity as ComponentEntity; + return strCmp(componentEntity.spec.type, type); + }; +} + +export function isNamespace(namespace: string) { + return (entity: Entity) => strCmp(entity?.metadata?.namespace, namespace); +} diff --git a/plugins/catalog/src/components/EntitySwitch/index.ts b/plugins/catalog/src/components/EntitySwitch/index.ts new file mode 100644 index 0000000000..089f2a108c --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/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 { EntitySwitch } from './EntitySwitch'; +export { isKind, isNamespace, isComponentType } from './conditions'; diff --git a/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx b/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx index ec1857fcf8..1c414414aa 100644 --- a/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx +++ b/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx @@ -15,10 +15,10 @@ */ import React, { ComponentProps } from 'react'; +import { useStarredEntities } from '@backstage/plugin-catalog-react'; import { IconButton, Tooltip, withStyles } from '@material-ui/core'; import StarBorder from '@material-ui/icons/StarBorder'; import Star from '@material-ui/icons/Star'; -import { useStarredEntities } from '../../hooks/useStarredEntities'; import { Entity } from '@backstage/catalog-model'; type Props = ComponentProps & { entity: Entity }; @@ -39,7 +39,7 @@ export const favouriteEntityIcon = (isStarred: boolean) => * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities * @param props MaterialUI IconButton props extended by required `entity` prop */ -export const FavouriteEntity: React.FC = props => { +export const FavouriteEntity = (props: Props) => { const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); const isStarred = isStarredEntity(props.entity); return ( diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx new file mode 100644 index 0000000000..58df0a53f8 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { HasComponentsCard } from './HasComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Components')).toBeInTheDocument(); + expect( + getByText(/No component is part of this system/i), + ).toBeInTheDocument(); + }); + + it('shows related components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Components')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx new file mode 100644 index 0000000000..eb8e96d9b3 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx @@ -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 { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +const columns = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; + +export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component is part of this system.{' '} + + Learn how to add components. + + + } + columns={columns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasComponentsCard/index.ts b/plugins/catalog/src/components/HasComponentsCard/index.ts new file mode 100644 index 0000000000..059392c3e8 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/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 { HasComponentsCard } from './HasComponentsCard'; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx new file mode 100644 index 0000000000..79b69d35ff --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { HasSubcomponentsCard } from './HasSubcomponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-components', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect( + getByText(/No subcomponent is part of this component/i), + ).toBeInTheDocument(); + }); + + it('shows related subcomponents', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx new file mode 100644 index 0000000000..778ec1dec5 --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -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 { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +const columns = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; + +export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No subcomponent is part of this component.{' '} + + Learn how to add subcomponents. + + + } + columns={columns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/index.ts b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts new file mode 100644 index 0000000000..cef0221d3b --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/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 { HasSubcomponentsCard } from './HasSubcomponentsCard'; diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx new file mode 100644 index 0000000000..08923a59c8 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +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'; +import { HasSystemsCard } from './HasSystemsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/No system is part of this domain/i)).toBeInTheDocument(); + }); + + it('shows related systems', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'System', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx new file mode 100644 index 0000000000..5b18711d44 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +const columns = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'system' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; + +export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No system is part of this domain.{' '} + + Learn how to add systems. + + + } + columns={columns} + entities={entities as SystemEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasSystemsCard/index.ts b/plugins/catalog/src/components/HasSystemsCard/index.ts new file mode 100644 index 0000000000..a29d257e7e --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/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 { HasSystemsCard } from './HasSystemsCard'; diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx index d105979600..9be1c995e8 100644 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.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 { render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ResultsFilter } from './ResultsFilter'; describe('Results Filter', () => { diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index fe1ba3a593..3e51b74dc2 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -15,15 +15,18 @@ */ import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; +import { + entityRoute, + rootRoute, + useEntity, +} from '@backstage/plugin-catalog-react'; import { Link, Typography } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; -import { useEntity } from '../hooks/useEntity'; -import { entityRoute, rootRoute } from '../routes'; import { CatalogPage } from './CatalogPage'; +import { EntityLoaderProvider } from './EntityLoaderProvider'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { EntityProvider } from './EntityProvider'; const DefaultEntityPage = () => ( @@ -50,7 +53,7 @@ const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { const { entity, loading, error } = useEntity(); // Loading and error states if (loading) return ; - if (error || (!loading && !entity)) return ; + if (error || !entity) return ; // Otherwise EntityPage provided from the App // Note that EntityPage will include EntityPageLayout already @@ -79,9 +82,9 @@ export const Router = ({ + - + } /> { const catalogApi = useApi(catalogApiRef); return useAsync(async () => { - const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const myLocation = + entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION]; if (!myLocation) { return []; } + + if (myLocation === 'bootstrap:bootstrap') { + throw new DeniedLocationException(myLocation); + } + const response = await catalogApi.getEntities({ - filter: { [LOCATION_ANNOTATION]: myLocation }, + filter: { + [`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: myLocation, + }, }); return response.items; }, [catalogApi, entity]); } -export const UnregisterEntityDialog: FC = ({ +export const UnregisterEntityDialog = ({ open, onConfirm, onClose, entity, -}) => { +}: Props) => { const { value: entities, loading, error } = useColocatedEntities(entity); - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const catalogApi = useApi(catalogApiRef); const alertApi = useApi(alertApiRef); + const configApi = useApi(configApiRef); const removeEntity = async () => { const uid = entity.metadata.uid; @@ -78,17 +93,31 @@ export const UnregisterEntityDialog: FC = ({ }; return ( - + Are you sure you want to unregister this entity? + {loading ? : null} + {error ? ( - {error.toString()} + {error.name === 'DeniedLocationException' ? ( + <> + You cannot unregister this entity, since it originates from a + protected Backstage configuration (location{' '} + {`"${(error as DeniedLocationException).locationName}"`}). If + you believe this is in error, please contact the{' '} + {configApi.getOptionalString('app.title') ?? 'Backstage'}{' '} + integrator. + + ) : ( + error.toString() + )} ) : null} + {entities?.length ? ( <> @@ -96,9 +125,10 @@ export const UnregisterEntityDialog: FC = ({
    - {entities.map(e => ( -
  • {e.metadata.name}
  • - ))} + {entities.map(e => { + const fullName = formatEntityRefTitle(e); + return
  • {fullName}
  • ; + })}
@@ -107,16 +137,21 @@ export const UnregisterEntityDialog: FC = ({
  • - {entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]} + { + entities[0]?.metadata.annotations?.[ + ORIGIN_LOCATION_ANNOTATION + ] + }
+ + To undo, just re-register the entity in Backstage. + ) : null} - - To undo, just re-register the entity in Backstage. -
+
diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 254e6f89ed..43cbbefe24 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GoogleIcon from '@material-ui/icons/CloudCircle'; @@ -124,7 +124,7 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -export const WorkflowRunsTableView: FC = ({ +export const WorkflowRunsTableView = ({ projectName, loading, pageSize, @@ -134,7 +134,7 @@ export const WorkflowRunsTableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
{ it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(cloudbuildPlugin).toBeDefined(); }); }); diff --git a/plugins/cloudbuild/src/plugin.ts b/plugins/cloudbuild/src/plugin.ts index ee997cc7ec..f5aaf4696a 100644 --- a/plugins/cloudbuild/src/plugin.ts +++ b/plugins/cloudbuild/src/plugin.ts @@ -18,6 +18,8 @@ import { createRouteRef, createApiFactory, googleAuthApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { cloudbuildApiRef, CloudbuildClient } from './api'; @@ -31,7 +33,7 @@ export const buildRouteRef = createRouteRef({ title: 'Cloudbuild Run', }); -export const plugin = createPlugin({ +export const cloudbuildPlugin = createPlugin({ id: 'cloudbuild', apis: [ createApiFactory({ @@ -42,4 +44,32 @@ export const plugin = createPlugin({ }, }), ], + routes: { + entityContent: rootRouteRef, + }, }); + +export const EntityCloudbuildContent = cloudbuildPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityLatestCloudbuildRunCard = cloudbuildPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LatestWorkflowRunCard), + }, + }), +); + +export const EntityLatestCloudbuildsForBranchCard = cloudbuildPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LatestWorkflowsForBranchCard), + }, + }), +); diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index b545b2ec37..622454d2a9 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,145 @@ # @backstage/plugin-cost-insights +## 0.8.2 + +### Patch Changes + +- 38205492a: Default alert properties can be overridden using accessors +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [1407b34c6] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [3a58084b6] +- Updated dependencies [a1f5e6545] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/config@0.1.3 + +## 0.8.1 + +### Patch Changes + +- b33fa4cf4: fixes a bug in default dismiss form where other text input persists between reason selections +- d36660721: Fix snooze quarter option +- 02d6803e8: Migrated to new composability API, exporting the plugin instance as `costInsightsPlugin`, the root `'/cost-insights'` page as `CostInsightsPage`, the `'/cost-insights/investigating-growth'` page as `CostInsightsProjectGrowthInstructionsPage`, and the `'/cost-insights/labeling-jobs'` page as `CostInsightsLabelDataflowInstructionsPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + +## 0.8.0 + +### Minor Changes + +- 19172f5a9: add alert hooks + +### Patch Changes + +- 4c6a6dddd: Fixed date calculations incorrectly converting to UTC in some cases. This should be a transparent change. +- 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.7.0 + +### Minor Changes + +- 19172f5a9: add alert hooks + +## 0.6.0 + +### Minor Changes + +- fac91bcc5: Add support for additional breakdowns of daily cost data. + This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. + +### Patch Changes + +- 8b7ef9f8b: Allow expand functionality to top panel product chart tooltip. + +## 0.5.7 + +### Patch Changes + +- 8c2437c15: bug(cost-insights): Remove entity count when none present +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + +## 0.5.6 + +### Patch Changes + +- 9e9504ce4: Minor wording change in UI + +## 0.5.5 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + +## 0.5.4 + +### Patch Changes + +- 3fca9adb9: Fix links in sample instructions +- Updated dependencies [a08c32ced] + - @backstage/core@0.4.3 + +## 0.5.3 + +### Patch Changes + +- c02defd57: Make alert url field optional + +## 0.5.2 + +### Patch Changes + +- 48c305e69: pin all projects selection to the top of menu list +- Updated dependencies [8ef71ed32] + - @backstage/core@0.4.1 + +## 0.5.1 + +### Patch Changes + +- 64c9fd84c: fix breakdown sorting +- Fix bar chart legend label bug for unlabeled dataflow alerts + +## 0.5.0 + +### Minor Changes + +- e3071a0d4: Add support for multiple types of entity cost breakdown. + + This change is backwards-incompatible with plugin-cost-insights 0.3.x; the `entities` field on Entity returned in product cost queries changed from `Entity[]` to `Record ( + + + + + {/* Global nav, not org-specific */} + + + + + + ++ + {/* End global nav */} + + + + + + ); +``` + ## Configuration Cost Insights has only two required configuration fields: a map of cloud `products` for showing cost breakdowns and `engineerCost` - the average yearly cost of an engineer including benefits. Products must be defined as keys on the `products` field. @@ -110,4 +144,4 @@ The CostInsightsApi `getAlerts` method may return any type of alert or recommend The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations. -The Alert `url` should link to documentation or instructions for resolving the alert. +The Alert `url` should link to documentation or instructions for resolving the alert. This may be omitted if no external link is needed. diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx index fe208d5bb8..e4732fc364 100644 --- a/plugins/cost-insights/dev/index.tsx +++ b/plugins/cost-insights/dev/index.tsx @@ -14,20 +14,15 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { createPlugin, createApiFactory } from '@backstage/core'; import { costInsightsApiRef } from '../src/api'; import { ExampleCostInsightsClient } from '../src/client'; -import { pluginConfig } from '../src/plugin'; +import { costInsightsPlugin } from '../src/plugin'; -const devPlugin = createPlugin({ - ...pluginConfig, - apis: [ - createApiFactory({ - api: costInsightsApiRef, - deps: {}, - factory: () => new ExampleCostInsightsClient(), - }), - ], -}); - -createDevApp().registerPlugin(devPlugin).render(); +createDevApp() + .registerPlugin(costInsightsPlugin) + .registerApi({ + api: costInsightsApiRef, + deps: {}, + factory: () => new ExampleCostInsightsClient(), + }) + .render(); diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 5a3ee38afe..5125dac83b 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.4.2", + "version": "0.8.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/cost-insights" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.3", + "@backstage/core": "^0.6.3", + "@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", @@ -36,6 +44,7 @@ "dayjs": "^1.9.4", "history": "^5.0.0", "moment": "^2.27.0", + "pluralize": "^8.0.0", "qs": "^6.9.4", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -46,14 +55,15 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/pluralize": "^0.0.29", "@types/recharts": "^1.8.14", "@types/regression": "^2.0.0", "@types/yup": "^0.29.8", diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx new file mode 100644 index 0000000000..31a4d7e0e9 --- /dev/null +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 pluralize from 'pluralize'; +import { MigrationAlertCard } from '../components/MigrationAlertCard'; +import { CostInsightsApi } from '../api'; +import { + Alert, + AlertForm, + AlertOptions, + AlertStatus, + AlertSnoozeFormData, + ChangeStatistic, + Entity, +} from '../types'; +import { MigrationDismissForm, MigrationDismissFormData } from '../forms'; + +export interface MigrationData { + startDate: string; + endDate: string; + change: ChangeStatistic; + services: Array; +} + +export interface MigrationAlert extends Alert { + api: CostInsightsApi; + data: MigrationData; +} + +/** + * This is an example of an Alert implementation using optional event hooks. + * + * Event hooks can be used to enable users to dismiss, snooze, or accept an action item + * - or any combination thereof. Defining a hook will generate default UI - button, dialog and + * form. Cost Insights does not preserve client side alert state - each hook is expected to return a new set of alerts. + * + * Snoozed, accepted, etc. alerts should define a corresponding status property. Alerts will be aggregated + * by status in a collapsed view below Alert Insights section and a badge will appear in Action Items + * showing the total alerts of that status. + * + * Customizing Alerts + * Default forms can be overridden in two ways - by setting a form property to null or defining a custom component. + * + * If a form property is set to null, the Dialog will not render a form. This can be useful in scenarios + * where data isn't needed from the user such as when a user accepts an action item's recommendation. + * + * If a form property is set to a React component, the Dialog will render the form component in place of the default form. + * Form components must return valid form elements, and accept a ref and onSubmit event handler. + * Custom forms must implement the corresponding event hook. See /forms for example implementations. + */ + +export class KubernetesMigrationAlert implements MigrationAlert { + api: CostInsightsApi; + data: MigrationData; + + subtitle = + 'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.'; + + // Dialog will not render a form if form property set to null. + AcceptForm = null; + // Overrides default Dismiss form with a custom form component. + DismissForm: AlertForm< + MigrationAlert, + MigrationDismissFormData + > = MigrationDismissForm; + + constructor(api: CostInsightsApi, data: MigrationData) { + this.api = api; + this.data = data; + } + + get title() { + return `Consider migrating ${pluralize( + 'service', + this.data.services.length, + true, + )} to Kubernetes.`; + } + + get element() { + const subheader = `${pluralize( + 'Service', + this.data.services.length, + true, + )}, sorted by cost`; + return ( + + ); + } + + /* Fires when the onSubmit event is raised on a Dismiss form. Displays custom dismiss form. */ + async onDismissed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Dismissed, + }, + ]), + ); + } + + /* Fires when the onSubmit event is raised on a Snooze form. Displays default snooze form. */ + async onSnoozed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Snoozed, + }, + ]), + ); + } + + /* Fires when the Accept button is clicked. Dialog does not render a form. See KubernetesMigrationAlert.AcceptForm */ + async onAccepted(options: AlertOptions): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Accepted, + }, + ]), + ); + } +} diff --git a/plugins/cost-insights/src/alerts/ProjectGrowthAlert.test.tsx b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.test.tsx new file mode 100644 index 0000000000..05a10bb2f0 --- /dev/null +++ b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 pluralize from 'pluralize'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ProjectGrowthAlert } from './ProjectGrowthAlert'; +import { ProjectGrowthData } from '../types'; +import { + MockCurrencyProvider, + MockConfigProvider, + MockBillingDateProvider, +} from '../utils/tests'; + +const mockData: ProjectGrowthData = { + project: 'test-project', + periodStart: '2021-01-01', + periodEnd: '2021-02-01', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0, + }, + products: [ + { + id: 'product-a', + aggregation: [0, 0], + }, + ], +}; + +// suppress recharts componentDidUpdate deprecation warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + +async function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + {children} + + , + ); +} + +class CustomProjectGrowthAlert extends ProjectGrowthAlert { + get url() { + return 'path/to/resource'; + } + get title() { + return `Investigate cost growth in ${pluralize( + 'project', + this.data.products.length, + true, + )}`; + } +} + +describe('ProjectGrowthAlert', () => { + describe('constructor', () => { + it('should create a project growth alert', async () => { + const alert = new ProjectGrowthAlert(mockData); + const { getByText, queryByText } = await renderInContext(alert.element); + + expect(alert.url).toBe('/cost-insights/investigating-growth'); + expect(alert.title).toBe( + 'Investigate cost growth in project test-project', + ); + expect(alert.subtitle).toBe( + 'Cost growth outpacing business growth is unsustainable long-term.', + ); + expect(getByText('1 product')).toBeInTheDocument(); + expect(queryByText('sorted by cost')).not.toBeInTheDocument(); + }); + + it('a subclass can inherit and override defaults using accessors', async () => { + const alert = new CustomProjectGrowthAlert(mockData); + const { getByText, queryByText } = await renderInContext(alert.element); + + expect(alert.url).toBe('path/to/resource'); + expect(alert.title).toBe('Investigate cost growth in 1 project'); + expect(alert.subtitle).toBe( + 'Cost growth outpacing business growth is unsustainable long-term.', + ); + expect(getByText('1 product')).toBeInTheDocument(); + expect(queryByText('sorted by cost')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx new file mode 100644 index 0000000000..088e85939d --- /dev/null +++ b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, ProjectGrowthData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class ProjectGrowthAlert implements Alert { + data: ProjectGrowthData; + + constructor(data: ProjectGrowthData) { + this.data = data; + } + + get url() { + return '/cost-insights/investigating-growth'; + } + + get title() { + return `Investigate cost growth in project ${this.data.project}`; + } + + get subtitle() { + return 'Cost growth outpacing business growth is unsustainable long-term.'; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.test.tsx b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.test.tsx new file mode 100644 index 0000000000..95e213f06a --- /dev/null +++ b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import pluralize from 'pluralize'; +import { renderInTestApp } from '@backstage/test-utils'; +import { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; +import { UnlabeledDataflowData } from '../types'; +import { + MockCurrencyProvider, + MockConfigProvider, + MockBillingDateProvider, +} from '../utils/tests'; + +const mockData: UnlabeledDataflowData = { + periodStart: '2021-02-01', + periodEnd: '2021-03-31', + unlabeledCost: 0, + labeledCost: 0, + projects: [ + { + id: 'project-a', + labeledCost: 0, + unlabeledCost: 0, + }, + ], +}; + +// suppress recharts componentDidUpdate deprecation warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + +async function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + {children} + + , + ); +} + +class CustomUnlabeledDataflowAlert extends UnlabeledDataflowAlert { + get url() { + return 'path/to/resource'; + } + get title() { + return `Add labels to ${pluralize( + 'workflow', + this.data.projects.length, + true, + )}`; + } +} + +describe('UnlabeledDataflowAlert', () => { + describe('constructor', () => { + it('should create an unlabeled dataflow alert', async () => { + const alert = new UnlabeledDataflowAlert(mockData); + const { getByText } = await renderInContext(alert.element); + + expect(alert.url).toBe('/cost-insights/labeling-jobs'); + expect(alert.title).toBe('Add labels to workflows'); + expect(alert.subtitle).toBe( + 'Labels show in billing data, enabling cost insights for each workflow.', + ); + expect( + getByText( + 'Showing costs from 1 project with unlabeled Dataflow jobs in the last 30 days.', + ), + ).toBeInTheDocument(); + }); + + it('a subclass can inherit and override defaults using accessors', async () => { + const alert = new CustomUnlabeledDataflowAlert(mockData); + const { getByText } = await renderInContext(alert.element); + + expect(alert.url).toBe('path/to/resource'); + expect(alert.title).toBe('Add labels to 1 workflow'); + expect(alert.subtitle).toBe( + 'Labels show in billing data, enabling cost insights for each workflow.', + ); + expect( + getByText( + 'Showing costs from 1 project with unlabeled Dataflow jobs in the last 30 days.', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx new file mode 100644 index 0000000000..7d11f3cc81 --- /dev/null +++ b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.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 { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; +import { Alert, AlertStatus, UnlabeledDataflowData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class UnlabeledDataflowAlert implements Alert { + data: UnlabeledDataflowData; + status?: AlertStatus; + + constructor(data: UnlabeledDataflowData) { + this.data = data; + } + + get url() { + return '/cost-insights/labeling-jobs'; + } + + get title() { + return 'Add labels to workflows'; + } + + get subtitle() { + return 'Labels show in billing data, enabling cost insights for each workflow.'; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/alerts/index.ts b/plugins/cost-insights/src/alerts/index.ts new file mode 100644 index 0000000000..b55c012a60 --- /dev/null +++ b/plugins/cost-insights/src/alerts/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 { ProjectGrowthAlert } from './ProjectGrowthAlert'; +export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; +export { KubernetesMigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationData } from './KubernetesMigrationAlert'; diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 9baad5ffab..5e94c4647b 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -18,7 +18,6 @@ import { createApiRef } from '@backstage/core'; import { Alert, Cost, - Duration, Entity, Group, Project, @@ -38,15 +37,9 @@ export type ProductInsightsOptions = { group: string; /** - * A time duration, such as P1M. See the Duration type for a detailed explanation - * of how the durations are interpreted in Cost Insights. + * An ISO 8601 repeating interval string, such as R2/P3M/2020-09-01 */ - duration: Duration; - - /** - * The most current date for which billing data is complete, in YYYY-MM-DD format. - */ - lastCompleteBillingDate: string; + intervals: string; /** * (optional) The project id from getGroupProjects or query parameters @@ -68,6 +61,8 @@ export type CostInsightsApi = { * if a user has multiple groups, they are able to switch between groups to see costs for each. * * This method should be removed once the Backstage identity plugin provides the same concept. + * + * @param userId The login id for the current user */ getUserGroups(userId: string): Promise; @@ -75,11 +70,13 @@ export type CostInsightsApi = { * Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a * similar concept in billing accounts). These act as filters for the displayed costs, users can * choose whether they see all costs for a group, or those from a particular owned project. + * + * @param group The group id from getUserGroups or query parameters */ getGroupProjects(group: string): Promise; /** - * Get daily cost aggregations for a given group and interval timeframe. + * Get daily cost aggregations for a given group and interval time frame. * * The return type includes an array of daily cost aggregations as well as statistics about the * change in cost over the intervals. Calculating these statistics requires us to bucket costs @@ -90,14 +87,14 @@ export type CostInsightsApi = { * reduction) and compare it to metrics important to the business. * * @param group The group id from getUserGroups or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getGroupDailyCost(group: string, intervals: string): Promise; /** * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar - * concept in billing accounts) and interval timeframe. + * concept in billing accounts) and interval time frame. * * The return type includes an array of daily cost aggregations as well as statistics about the * change in cost over the intervals. Calculating these statistics requires us to bucket costs @@ -108,24 +105,24 @@ export type CostInsightsApi = { * (or reduction) and compare it to metrics important to the business. * * @param project The project id from getGroupProjects or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getProjectDailyCost(project: string, intervals: string): Promise; /** - * Get aggregations for a particular metric and interval timeframe. Teams + * Get aggregations for a particular metric and interval time frame. Teams * can see metrics important to their business in comparison to the growth * (or reduction) of a project or group's daily costs. * * @param metric A metric from the cost-insights configuration in app-config.yaml. - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getDailyMetricData(metric: string, intervals: string): Promise; /** - * Get cost aggregations for a particular cloud product and interval timeframe. This includes + * Get cost aggregations for a particular cloud product and interval time frame. This includes * total cost for the product, as well as a breakdown of particular entities that incurred cost * in this product. The type of entity depends on the product - it may be deployed services, * storage buckets, managed database instances, etc. @@ -136,9 +133,11 @@ export type CostInsightsApi = { * The time period is supplied as a Duration rather than intervals, since this is always expected * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). * - * @param options Options to use when fetching insights for a particular cloud product and interval timeframe. + * @param options Options to use when fetching insights for a particular cloud product and + * interval time frame. */ getProductInsights(options: ProductInsightsOptions): Promise; + /** * Get current cost alerts for a given group. These show up as Action Items for the group on the * Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 0ba57f76fa..2e70170fc1 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -31,13 +31,15 @@ import { import { ProjectGrowthAlert, UnlabeledDataflowAlert, -} from '../src/utils/alerts'; + KubernetesMigrationAlert, +} from '../src/alerts'; import { - trendlineOf, + aggregationFor, changeOf, entityOf, getGroupedProducts, - aggregationFor, + getGroupedProjects, + trendlineOf, } from './utils/mockData'; export class ExampleCostInsightsClient implements CostInsightsApi { @@ -99,9 +101,12 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), - // Optional field on Cost which needs to be supplied in order to see - // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + // Optional field providing cost groupings / breakdowns keyed by the type. In this example, + // daily cost grouped by cloud product OR by project / billing account. + groupedCosts: { + product: getGroupedProducts(intervals), + project: getGroupedProjects(intervals), + }, }, ); @@ -117,9 +122,11 @@ export class ExampleCostInsightsClient implements CostInsightsApi { aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), - // Optional field on Cost which needs to be supplied in order to see - // the product breakdown view in the top panel. - groupedCosts: getGroupedProducts(intervals), + // Optional field providing cost groupings / breakdowns keyed by the type. In this example, + // daily project cost grouped by cloud product. + groupedCosts: { + product: getGroupedProducts(intervals), + }, }, ); @@ -171,9 +178,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi { ], }; + const today = dayjs(); const alerts: Alert[] = await this.request({ group }, [ new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), + new KubernetesMigrationAlert(this, { + startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT), + endDate: today.format(DEFAULT_DATE_FORMAT), + change: { + ratio: 0, + amount: 0, + }, + services: [ + { + id: 'service-a', + aggregation: [20_000, 10_000], + change: { + ratio: -0.5, + amount: -10_000, + }, + entities: {}, + }, + { + id: 'service-b', + aggregation: [30_000, 15_000], + change: { + ratio: -0.5, + amount: -15_000, + }, + entities: {}, + }, + ], + }), ]); return alerts; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx similarity index 87% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx rename to plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index d896bf0d47..b72f050e5b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { AlertActionCard } from './AlertActionCard'; +import { ActionItemCard } from './ActionItemCard'; import { MockScrollProvider } from '../../utils/tests'; -import { ProjectGrowthAlert } from '../../utils/alerts'; +import { ProjectGrowthAlert } from '../../alerts'; import { ProjectGrowthData } from '../../types'; const data: ProjectGrowthData = { @@ -31,11 +31,11 @@ const data: ProjectGrowthData = { }; const alert = new ProjectGrowthAlert(data); -describe('', () => { +describe('', () => { it('Renders an alert', async () => { const rendered = await renderInTestApp( - , + 1} /> , ); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx new file mode 100644 index 0000000000..3c95fdaa2f --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { MouseEventHandler } from 'react'; +import classnames from 'classnames'; +import { Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type ActionItemCardProps = { + alert: Alert; + number?: number; + avatar?: JSX.Element; + disableScroll?: boolean; +}; + +export const ActionItemCard = ({ + alert, + avatar, + number, + disableScroll = false, +}: ActionItemCardProps) => { + const classes = useStyles(); + const rootClasses = classnames(classes.root, { + [classes.activeRoot]: !disableScroll, + }); + const [, setScroll] = useScroll(); + + const onActionItemClick: MouseEventHandler = () => { + if (!disableScroll && number) { + setScroll(`alert-${number}`); + } + }; + + return ( + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx new file mode 100644 index 0000000000..677ba99c6a --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ActionItems } from './ActionItems'; +import { MockScrollProvider } from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render({children}); +} + +describe('', () => { + it('should not display status buttons if there are no active alerts', () => { + const { queryByRole } = renderInContext( + , + ); + expect(queryByRole('button', { name: 'snoozed' })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: 'accepted' })).not.toBeInTheDocument(); + expect( + queryByRole('button', { name: 'dismissed' }), + ).not.toBeInTheDocument(); + }); + + it('should display status buttons with correct badge number', () => { + const { getByText, getByRole, getAllByText } = renderInContext( + , + ); + expect(getAllByText('1')).toHaveLength(2); // should be a badge of 1 and action item number of 1 + expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); + expect(getByRole('button', { name: 'snoozed' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'accepted' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'dismissed' })).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx new file mode 100644 index 0000000000..4fbe3470b1 --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { Fragment, MouseEventHandler } from 'react'; +import { + Avatar, + Badge, + Box, + IconButtonProps, + IconButton, + Paper, + Divider, + Tooltip, +} 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 { ActionItemCard } from './ActionItemCard'; +import { Alert, AlertStatus } from '../../types'; +import { useScroll, ScrollType } from '../../hooks'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type AlertStatusButtonProps = { + title: string; + amount: number; + icon: JSX.Element; + onClick: MouseEventHandler; +} & IconButtonProps; + +const AlertStatusButton = ({ + title, + amount, + icon, + onClick, + ...buttonProps +}: AlertStatusButtonProps) => ( + + + {icon} + + +); + +type ActionItemsProps = { + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const ActionItems = ({ + active, + snoozed, + accepted, + dismissed, +}: ActionItemsProps) => { + const classes = useStyles(); + const [, setScroll] = useScroll(); + + const isSnoozedButtonDisplayed = !!snoozed.length; + const isAcceptedButtonDisplayed = !!accepted.length; + const isDismissedButtonDisplayed = !!dismissed.length; + const isStatusButtonGroupDisplayed = !!active.length; + + const onStatusButtonClick: MouseEventHandler = () => + setScroll(ScrollType.AlertSummary); + + return ( + <> + + {active.map((alert, index) => ( + + {index + 1}} + /> + {index < active.length - 1 && } + + ))} + + {isStatusButtonGroupDisplayed && ( + + {isAcceptedButtonDisplayed && ( + } + amount={accepted.length} + onClick={onStatusButtonClick} + /> + )} + {isSnoozedButtonDisplayed && ( + } + onClick={onStatusButtonClick} + /> + )} + {isDismissedButtonDisplayed && ( + } + amount={dismissed.length} + onClick={onStatusButtonClick} + /> + )} + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/index.ts b/plugins/cost-insights/src/components/ActionItems/index.ts new file mode 100644 index 0000000000..b63e34b0af --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/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 { ActionItems } from './ActionItems'; +export { ActionItemCard } from './ActionItemCard'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx deleted file mode 100644 index 78feade952..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx +++ /dev/null @@ -1,45 +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 { Avatar, Card, CardHeader } from '@material-ui/core'; -import { useScroll } from '../../hooks'; -import { Alert } from '../../types'; -import { - useAlertActionCardHeader as useHeaderStyles, - useAlertActionCardStyles as useStyles, -} from '../../utils/styles'; - -type AlertActionCardProps = { - alert: Alert; - number: number; -}; - -export const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { - const { scrollIntoView } = useScroll(`alert-${number}`); - const headerClasses = useHeaderStyles(); - const classes = useStyles(); - - return ( - - {number}} - title={alert.title} - subheader={alert.subtitle} - /> - - ); -}; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/AlertActionCardList/index.ts deleted file mode 100644 index a0ce118482..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { AlertActionCardList } from './AlertActionCardList'; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx new file mode 100644 index 0000000000..7061f0736b --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { capitalize } from '@material-ui/core'; +import { AlertDialog } from './AlertDialog'; +import { render } from '@testing-library/react'; +import { Alert, AlertFormProps, AlertStatus } from '../../types'; + +type MockFormDataProps = AlertFormProps; + +function createForm(title: string) { + return React.forwardRef((props, ref) => ( +
+ You. {title}. Me. + + )); +} + +const snoozableAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), +}; + +const dimissableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const acceptableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const customSnoozeAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createForm('Snooze'), +}; + +const customDismissAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createForm('Dismiss'), +}; + +const customAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: createForm('Accept'), +}; + +const nullAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const nullDismissAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +const nullSnoozeAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +describe('', () => { + describe.each` + alert | status | action | text + ${acceptableAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${dimissableAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${snoozableAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Default forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Displays a default ${action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(text)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); + + describe.each` + alert | status | action | text + ${customAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${customDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${customSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Custom forms', ({ alert, status, action: [action, actioned] }) => { + it(`Displays a custom ${capitalize(action)} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(`You. ${capitalize(action)}. Me.`)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); + + describe.each` + alert | status | action | text + ${nullAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${nullDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${nullSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Null forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Does NOT display a ${capitalize(action)} form`, () => { + const { getByText, getByRole, queryByText } = render( + , + ); + expect(queryByText(text)).not.toBeInTheDocument(); + expect(getByRole('button', { name: action })).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx new file mode 100644 index 0000000000..ab6f9fb9f0 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, useState } from 'react'; +import { + capitalize, + Box, + Button, + Divider, + Dialog, + DialogActions, + IconButton, + DialogContent, + Typography, +} from '@material-ui/core'; +import { default as CloseIcon } from '@material-ui/icons/Close'; +import { useAlertDialogStyles as useStyles } from '../../utils/styles'; +import { Alert, AlertStatus, Maybe } from '../../types'; +import { choose, formOf } from '../../utils/alerts'; + +const DEFAULT_FORM_ID = 'alert-form'; + +type AlertDialogProps = { + open: boolean; + group: string; + alert: Maybe; + status: Maybe; + onClose: () => void; + onSubmit: (data: any) => void; +}; + +export const AlertDialog = ({ + open, + group, + alert, + status, + onClose, + onSubmit, +}: AlertDialogProps) => { + const classes = useStyles(); + const [isSubmitDisabled, setSubmitDisabled] = useState(true); + const formRef = useRef>(null); + + useEffect(() => { + setSubmitDisabled(open); + }, [open]); + + function disableSubmit(isDisabled: boolean) { + setSubmitDisabled(isDisabled); + } + + function onDialogClose() { + onClose(); + setSubmitDisabled(true); + } + + const [action, actioned] = choose( + status, + [ + ['snooze', 'snoozed'], + ['accept', 'accepted'], + ['dismiss', 'dismissed'], + ], + ['', ''], + ); + + const TransitionProps = { + mountOnEnter: true, + unmountOnExit: true, + onEntered() { + if (formRef.current) { + formRef.current.id = DEFAULT_FORM_ID; + } + }, + }; + + const Form = formOf(alert, status); + + return ( + + + + + + + + + + {capitalize(action)} this action item? + + + + This action item will be {actioned} for all of {group}. + + + + + + {alert?.title} + + {alert?.subtitle} + + {Form && ( +
+ )} +
+ + + {Form ? ( + + ) : ( + + )} + +
+ ); +}; 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 new file mode 100644 index 0000000000..937e099356 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { AlertInsightsSection } from './AlertInsightsSection'; +import { render } from '@testing-library/react'; +import { Alert } from '../../types'; +import { MockScrollProvider } from '../../utils/tests'; + +const mockAlert: Alert = { + 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, 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: Alert = { + ...mockAlert, + url: undefined, + }; + 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 426e943092..d52687ae19 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -15,31 +15,86 @@ */ 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 ( - - - - - {/* */} - + + + {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/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 9c76d6d998..12668140b6 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -54,7 +54,7 @@ describe.each` it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -73,7 +73,7 @@ describe.each` it(`formats ${usd.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -92,7 +92,7 @@ describe.each` it(`formats ${carbon.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/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 44b7497754..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx +++ /dev/null @@ -1,210 +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/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx index 3442de49fc..c3f4b4f79c 100644 --- a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; -import { CodeSnippet } from '@backstage/core'; +import { CodeSnippet, Link } from '@backstage/core'; import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; export const LabelDataflowInstructionsPage = () => { @@ -31,10 +31,10 @@ export const LabelDataflowInstructionsPage = () => { In Cloud Dataflow, labels can be added to a job either programmatically - or via the command-line when launching a job. Note that GCP has - + or via the command-line when launching a job. Note that GCP has{' '} + restrictions - {' '} + {' '} on the length and characters that can be used in labels. @@ -46,9 +46,9 @@ export const LabelDataflowInstructionsPage = () => { DataflowPipelineOptions Dataflow jobs using Beam's{' '} - + DataflowPipelineOptions - {' '} + {' '} directly can use the setLabels function to add one or more labels: "my-dataflow-job For more information on specifying options, see the{' '} - + Dataflow documentation - {' '} + {' '} or{' '} - + Scio Scaladoc - + . 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/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index a3519ff61b..ae513c04e0 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -66,7 +66,6 @@ describe('', () => { describe.each` duration - ${Duration.P1M} ${Duration.P3M} ${Duration.P90D} ${Duration.P30D} @@ -74,8 +73,9 @@ describe('', () => { it(`Should select ${duration}`, async () => { const mockOnSelect = jest.fn(); const mockAggregation = + // Can't select an option that's already the default DefaultPageFilters.duration === duration - ? Duration.P1M + ? Duration.P30D : DefaultPageFilters.duration; const rendered = await renderInTestApp( @@ -89,7 +89,6 @@ describe('', () => { const button = getByRole(periodSelect, 'button'); UserEvent.click(button); - await waitFor(() => rendered.getByText('Past 60 Days')); UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); expect(mockOnSelect).toHaveBeenLastCalledWith(duration); }); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 4908641d6f..459de68921 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -17,10 +17,7 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; import { Duration } from '../../types'; -import { - formatLastTwoLookaheadQuarters, - formatLastTwoMonths, -} from '../../utils/formatters'; +import { formatLastTwoLookaheadQuarters } from '../../utils/formatters'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; import { useLastCompleteBillingDate } from '../../hooks'; @@ -42,10 +39,6 @@ export function getDefaultOptions( value: Duration.P30D, label: 'Past 60 Days', }, - { - value: Duration.P1M, - label: formatLastTwoMonths(lastCompleteBillingDate), - }, { value: Duration.P3M, label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx index 85f39f9388..3719bc8f6c 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -40,7 +40,7 @@ const MockComputeEngine: Product = { const MockComputeEngineInsights: Entity = { id: 'compute-engine', - entities: [], + entities: {}, aggregation: [0, 0], change: { ratio: 0, @@ -55,7 +55,7 @@ const MockCloudDataflow: Product = { const MockCloudDataflowInsights: Entity = { id: MockCloudDataflow.kind, - entities: [], + entities: {}, aggregation: [1_000, 2_000], change: { ratio: 1, @@ -70,7 +70,7 @@ const MockCloudStorage: Product = { const MockCloudStorageInsights: Entity = { id: MockCloudStorage.kind, - entities: [], + entities: {}, aggregation: [2_000, 4_000], change: { ratio: 1, @@ -85,7 +85,7 @@ const MockBigQuery: Product = { const MockBigQueryInsights: Entity = { id: MockBigQuery.kind, - entities: [], + entities: {}, aggregation: [8_000, 16_000], change: { ratio: 1, @@ -100,7 +100,7 @@ const MockBigTable: Product = { const MockBigTableInsights: Entity = { id: MockBigTable.kind, - entities: [], + entities: {}, aggregation: [16_000, 32_000], change: { ratio: 1, @@ -115,7 +115,7 @@ const MockCloudPubSub: Product = { const MockCloudPubSubInsights: Entity = { id: MockCloudPubSub.kind, - entities: [], + entities: {}, aggregation: [32_000, 64_000], change: { ratio: 1, @@ -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( @@ -97,16 +91,15 @@ export const ProductInsights = ({ group: group, project: project, product: product.kind, - duration: DEFAULT_DURATION, - lastCompleteBillingDate: lastCompleteBillingDate, + intervals: intervalsOf(DEFAULT_DURATION, lastCompleteBillingDate), }), ), ).then(settledResponseOf); - const initialStates = initialStatesOf(products, responses).sort( + const updatedInitialStates = initialStatesOf(products, responses).sort( totalAggregationSort, ); - setStates(initialStates); + setStates(updatedInitialStates); } catch (e) { setError(e); } finally { @@ -114,7 +107,7 @@ export const ProductInsights = ({ } } - getAllProductInsights(group, project, products, lastCompleteBillingDate); + getAllProductInsights(); }, [ client, group, @@ -127,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/ProductEntityDialog.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx new file mode 100644 index 0000000000..99b6c8ae26 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ProductEntityDialog } from './ProductEntityDialog'; +import { render } from '@testing-library/react'; +import { Entity } from '../../types'; + +const atomicEntity: Entity = { + id: null, + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, +}; + +const singleBreakdownEntity = { + ...atomicEntity, + entities: { + SKU: [ + { + id: 'sku-1', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + { + id: 'sku-2', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + ] as Entity[], + }, +}; + +const multiBreakdownEntity = { + ...singleBreakdownEntity, + entities: { + ...singleBreakdownEntity.entities, + deployment: [ + { + id: 'd-1', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + { + id: 'd-2', + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: {}, + }, + ] as Entity[], + }, +}; + +describe('', () => { + it('Should error if no sub-entities exist', () => { + expect(() => + render( + wrapInTestApp( + , + ), + ), + ).toThrow(); + }); + + it('Should show a tab for a single sub-entity type', () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + expect(getByText('Breakdown by SKU')).toBeInTheDocument(); + }); + + it('Should show tabs when multiple sub-entity types exist', () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + expect(getByText('Breakdown by SKU')).toBeInTheDocument(); + expect(getByText('Breakdown by deployment')).toBeInTheDocument(); + expect(getByText('sku-1')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx index d617c0df35..a98e5dc87a 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx @@ -14,179 +14,55 @@ * limitations under the License. */ -import React from 'react'; -import classnames from 'classnames'; -import { Table, TableColumn } from '@backstage/core'; -import { Dialog, IconButton, Typography } from '@material-ui/core'; +import React, { useState } from 'react'; +import { HeaderTabs } from '@backstage/core'; +import { Dialog, IconButton } from '@material-ui/core'; import { default as CloseButton } from '@material-ui/icons/Close'; -import { CostGrowthIndicator } from '../CostGrowth'; -import { costFormatter, formatPercent } from '../../utils/formatters'; import { useEntityDialogStyles as useStyles } from '../../utils/styles'; -import { BarChartOptions, Entity } from '../../types'; - -function createRenderer(col: keyof RowData, classes: Record) { - return function render(rowData: {}): JSX.Element { - const row = rowData as RowData; - const rowStyles = classnames(classes.row, { - [classes.rowTotal]: row.id === 'total', - [classes.colFirst]: col === 'label', - [classes.colLast]: col === 'ratio', - }); - - switch (col) { - case 'previous': - case 'current': - return ( - - {costFormatter.format(row[col])} - - ); - case 'ratio': - return ( - formatPercent(Math.abs(amount))} - /> - ); - default: - return {row.label}; - } - }; -} - -// material-table does not support fixed rows. Override the sorting algorithm -// to force Total row to bottom by default or when a user sort toggles a column. -function createSorter(field?: keyof Omit) { - return function rowSort(data1: {}, data2: {}): number { - const a = data1 as RowData; - const b = data2 as RowData; - if (a.id === 'total') return 1; - if (b.id === 'total') return 1; - if (field === 'label') return a.label.localeCompare(b.label); - - return field - ? a[field] - b[field] - : b.previous + b.current - (a.previous - a.current); - }; -} - -const defaultEntity: Entity = { - id: null, - aggregation: [0, 0], - change: { ratio: 0, amount: 0 }, - entities: [], -}; - -type RowData = { - id: string; - label: string; - previous: number; - current: number; - ratio: number; -}; - -type ProductEntityDialogOptions = Partial< - Pick ->; +import { Entity } from '../../types'; +import { + ProductEntityTable, + ProductEntityTableOptions, +} from './ProductEntityTable'; +import { findAlways } from '../../utils/assert'; type ProductEntityDialogProps = { open: boolean; - entity?: Entity; - entitiesLabel: string; - options?: ProductEntityDialogOptions; + entity: Entity; + options?: ProductEntityTableOptions; onClose: () => void; }; export const ProductEntityDialog = ({ open, - entity = defaultEntity, - entitiesLabel, + entity, options = {}, onClose, }: ProductEntityDialogProps) => { const classes = useStyles(); - - const data = Object.assign( - { - previousName: 'Previous', - currentName: 'Current', - }, - options, + const labels = Object.keys(entity.entities); + const [selectedLabel, setSelectedLabel] = useState( + findAlways(labels, _ => true), ); - const firstColClasses = classnames(classes.column, classes.colFirst); - const lastColClasses = classnames(classes.column, classes.colLast); - - const columns: TableColumn[] = [ - { - field: 'label', - title: ( - {entitiesLabel} - ), - render: createRenderer('label', classes), - customSort: createSorter('label'), - width: '33.33%', - }, - { - field: 'previous', - title: ( - {data.previousName} - ), - align: 'right', - render: createRenderer('previous', classes), - customSort: createSorter('previous'), - }, - { - field: 'current', - title: ( - {data.currentName} - ), - align: 'right', - render: createRenderer('current', classes), - customSort: createSorter('current'), - }, - { - field: 'ratio', - title: M/M, - align: 'right', - render: createRenderer('ratio', classes), - customSort: createSorter('ratio'), - }, - ]; - - const rowData: RowData[] = entity.entities - .map(e => ({ - id: e.id || 'Unknown', - label: e.id || 'Unknown', - previous: e.aggregation[0], - current: e.aggregation[1], - ratio: e.change.ratio, - })) - .concat({ - id: 'total', - label: 'Total', - previous: entity.aggregation[0], - current: entity.aggregation[1], - ratio: entity.change.ratio, - }) - .sort(createSorter()); + const tabs = labels.map((label, index) => ({ + id: index.toString(), + label: `Breakdown by ${label}`, + })); return ( -
setSelectedLabel(labels[index])} + /> + ); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx new file mode 100644 index 0000000000..45576bda65 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import classnames from 'classnames'; +import { Table, TableColumn } from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { costFormatter, formatPercent } from '../../utils/formatters'; +import { useEntityDialogStyles as useStyles } from '../../utils/styles'; +import { CostGrowthIndicator } from '../CostGrowth'; +import { BarChartOptions, Entity } from '../../types'; + +export type ProductEntityTableOptions = Partial< + Pick +>; + +type RowData = { + id: string; + label: string; + previous: number; + current: number; + ratio: number; +}; + +function createRenderer(col: keyof RowData, classes: Record) { + return function render(rowData: {}): JSX.Element { + const row = rowData as RowData; + const rowStyles = classnames(classes.row, { + [classes.rowTotal]: row.id === 'total', + [classes.colFirst]: col === 'label', + [classes.colLast]: col === 'ratio', + }); + + switch (col) { + case 'previous': + case 'current': + return ( + + {costFormatter.format(row[col])} + + ); + case 'ratio': + return ( + formatPercent(Math.abs(amount))} + /> + ); + default: + return {row.label}; + } + }; +} + +// material-table does not support fixed rows. Override the sorting algorithm +// to force Total row to bottom by default or when a user sort toggles a column. +function createSorter(field?: keyof Omit) { + return function rowSort(data1: {}, data2: {}): number { + const a = data1 as RowData; + const b = data2 as RowData; + if (a.id === 'total') return 1; + if (b.id === 'total') return 1; + if (field === 'label') return a.label.localeCompare(b.label); + + return field + ? a[field] - b[field] + : b.previous + b.current - (a.previous + a.current); + }; +} + +type ProductEntityTableProps = { + entityLabel: string; + entity: Entity; + options: ProductEntityTableOptions; +}; + +export const ProductEntityTable = ({ + entityLabel, + entity, + options, +}: ProductEntityTableProps) => { + const classes = useStyles(); + const entities = entity.entities[entityLabel]; + + const data = Object.assign( + { + previousName: 'Previous', + currentName: 'Current', + }, + options, + ); + + const firstColClasses = classnames(classes.column, classes.colFirst); + const lastColClasses = classnames(classes.column, classes.colLast); + + const columns: TableColumn[] = [ + { + field: 'label', + title: {entityLabel}, + render: createRenderer('label', classes), + customSort: createSorter('label'), + width: '33.33%', + }, + { + field: 'previous', + title: ( + {data.previousName} + ), + align: 'right', + render: createRenderer('previous', classes), + customSort: createSorter('previous'), + }, + { + field: 'current', + title: ( + {data.currentName} + ), + align: 'right', + render: createRenderer('current', classes), + customSort: createSorter('current'), + }, + { + field: 'ratio', + title: Change, + align: 'right', + render: createRenderer('ratio', classes), + customSort: createSorter('ratio'), + }, + ]; + + const rowData: RowData[] = entities + .map(e => ({ + id: e.id || 'Unknown', + label: e.id || 'Unknown', + previous: e.aggregation[0], + current: e.aggregation[1], + ratio: e.change.ratio, + })) + .concat({ + id: 'total', + label: 'Total', + previous: entity.aggregation[0], + current: entity.aggregation[1], + ratio: entity.change.ratio, + }) + .sort(createSorter()); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index 385ff82f9b..a2f87b1d86 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -42,7 +42,7 @@ const costInsightsApi = (entity: Entity): Partial => ({ const mockProductCost = createMockEntity(() => ({ id: 'test-id', - entities: [], + entities: {}, aggregation: [3000, 4000], change: { ratio: 0.23, @@ -77,46 +77,36 @@ const renderProductInsightsCardInTestApp = async ( ); describe('', () => { - it('Renders the scroll anchors', async () => { - const rendered = await renderProductInsightsCardInTestApp( - mockProductCost, - MockComputeEngine, - Duration.P1M, - ); - expect( - rendered.queryByTestId(`scroll-test-compute-engine`), - ).toBeInTheDocument(); - }); - it('Should render the right subheader for products with cost data', async () => { const entity = { ...mockProductCost, - entities: [...Array(1000)].map(createMockEntity), + entities: { entity: [...Array(1000)].map(createMockEntity) }, }; const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, ); - const subheader = 'entities, sorted by cost'; - const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`); - expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect( + rendered.getByText(/1000 entities, sorted by cost/), + ).toBeInTheDocument(); }); it('Should render the right subheader if there is no cost data or change data', async () => { const entity: Entity = { id: 'test-id', - entities: [], + entities: {}, aggregation: [0, 0], change: { ratio: 0, amount: 0 }, }; - const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const subheader = `There are no ${MockComputeEngine.name} costs within this time frame for your team's projects.`; const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, - Duration.P1M, + Duration.P30D, ); 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(); @@ -135,7 +125,7 @@ describe('', () => { it(`Should display the correct relative time for ${duration}`, async () => { const entity = { ...mockProductCost, - entities: [...Array(3)].map(createMockEntity), + entities: { entity: [...Array(3)].map(createMockEntity) }, }; const rendered = await renderProductInsightsCardInTestApp( entity, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index e633d6653d..d46737cafb 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -21,6 +21,7 @@ import React, { useRef, useState, } from 'react'; +import pluralize from 'pluralize'; import { InfoCard } from '@backstage/core'; import { Typography } from '@material-ui/core'; import { default as Alert } from '@material-ui/lab/Alert'; @@ -30,12 +31,12 @@ import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; import { DefaultLoadingAction } from '../../utils/loading'; import { Duration, Entity, Maybe, Product } from '../../types'; import { - useLastCompleteBillingDate, - useScroll, - useLoading, MapLoadingToProps, + useLastCompleteBillingDate, + useLoading, } from '../../hooks'; -import { pluralOf } from '../../utils/grammar'; +import { findAnyKey } from '../../utils/assert'; +import { ScrollAnchor } from '../../utils/scroll'; type LoadingProps = (isLoading: boolean) => void; @@ -59,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(); @@ -91,14 +91,14 @@ export const ProductInsightsCard = ({ } }, [product, duration, onSelectAsync, dispatchLoadingProduct]); - const entities = entity?.entities ?? []; - const subheader = entities.length - ? `${entities.length} ${pluralOf( - entities.length, - 'entity', - 'entities', - )}, sorted by cost` - : null; + // Only a single entities Record for the root product entity is supported + const entityKey = findAnyKey(entity?.entities); + const entities = entityKey ? entity!.entities[entityKey] : []; + + 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/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx index 2c26f62574..ace2766277 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -20,6 +20,7 @@ import { TooltipProps as RechartsTooltipProps, RechartsFunction, } from 'recharts'; +import pluralize from 'pluralize'; import { Box, Typography } from '@material-ui/core'; import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen'; import { LegendItem } from '../LegendItem'; @@ -32,8 +33,13 @@ import { BarChartTooltipItem, BarChartLegendOptions, } from '../BarChart'; -import { pluralOf } from '../../utils/grammar'; -import { findAlways, notEmpty, isUndefined } from '../../utils/assert'; +import { + findAlways, + notEmpty, + isUndefined, + findAnyKey, + assertAlways, +} from '../../utils/assert'; import { formatPeriod, formatPercent } from '../../utils/formatters'; import { titleOf, @@ -62,19 +68,28 @@ export const ProductInsightsChart = ({ }: ProductInsightsChartProps) => { const classes = useStyles(); const layoutClasses = useLayoutStyles(); + + // Only a single entities Record for the root product entity is supported + const entities = useMemo(() => { + const entityLabel = assertAlways(findAnyKey(entity.entities)); + return entity.entities[entityLabel] ?? []; + }, [entity]); + const [activeLabel, setActive] = useState>(); const [selectLabel, setSelected] = useState>(); const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]); + const isClickable = useMemo(() => { - const breakdownEntities = - entity.entities.find(e => e.id === activeLabel)?.entities ?? []; - return breakdownEntities.length > 0; - }, [entity, activeLabel]); + const breakdowns = Object.keys( + entities.find(e => e.id === activeLabel)?.entities ?? {}, + ); + return breakdowns.length > 0; + }, [entities, activeLabel]); const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`; const costStart = entity.aggregation[0]; const costEnd = entity.aggregation[1]; - const resources = entity.entities.map(resourceOf); + const resources = entities.map(resourceOf); const options: Partial = { previousName: formatPeriod(duration, billingDate, false), @@ -120,15 +135,14 @@ export const ProductInsightsChart = ({ const title = titleOf(label); const items = payload.map(tooltipItemOf).filter(notEmpty); - const activeEntity = findAlways(entity.entities, e => e.id === id); + const activeEntity = findAlways(entities, e => e.id === id); const ratio = activeEntity.change.ratio; - const breakdownEntities = activeEntity.entities; - const subtitle = `${breakdownEntities.length} ${pluralOf( - breakdownEntities.length, - entity.entitiesLabel || 'SKU', - )}`; + const breakdowns = Object.keys(activeEntity.entities); - if (breakdownEntities.length) { + if (breakdowns.length) { + const subtitle = breakdowns + .map(b => pluralize(b, activeEntity.entities[b].length, true)) + .join(', '); return ( - {isSelected && entity.entities.length && ( + {isSelected && entities.length && ( setSelected(undefined)} - entity={entity.entities.find(e => e.id === selectLabel)} + entity={findAlways(entities, e => e.id === selectLabel)} options={options} - entitiesLabel={entity.entitiesLabel || 'SKU'} /> )} diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index db3f2f9bf6..0e24f042bc 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -27,6 +27,9 @@ import { AlertCost } from '../../types'; import { defaultCurrencies } from '../../utils/currency'; import { findAlways } from '../../utils/assert'; +// suppress recharts componentDidUpdate deprecation warnings +jest.spyOn(console, 'warn').mockImplementation(() => {}); + const engineers = findAlways(defaultCurrencies, c => c.kind === null); const MockProject = 'test-project-1'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index bcda21e0af..a3df76088c 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -15,10 +15,10 @@ */ import React from 'react'; +import pluralize from 'pluralize'; import { InfoCard } from '@backstage/core'; import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart'; import { ProjectGrowthData } from '../../types'; -import { pluralOf } from '../../utils/grammar'; type ProjectGrowthAlertProps = { alert: ProjectGrowthData; @@ -26,7 +26,7 @@ type ProjectGrowthAlertProps = { export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { const subheader = ` - ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + ${pluralize('product', alert.products.length, true)}${ alert.products.length > 1 ? ', sorted by cost' : '' }`; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index 41b4ca90f6..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); @@ -72,26 +72,28 @@ export const ProjectGrowthInstructionsPage = () => { ratio: 3, amount: 40_000, }, - entities: [ - { - id: 'service-one', - aggregation: [18_200, 58_500], - entities: [], - change: { ratio: 2.21, amount: 40_300 }, - }, - { - id: 'service-two', - aggregation: [1200, 1300], - entities: [], - change: { ratio: 0.083, amount: 100 }, - }, - { - id: 'service-three', - aggregation: [600, 200], - entities: [], - change: { ratio: -0.666, amount: -400 }, - }, - ], + entities: { + service: [ + { + id: 'service-one', + aggregation: [18_200, 58_500], + entities: {}, + change: { ratio: 2.21, amount: 40_300 }, + }, + { + id: 'service-two', + aggregation: [1200, 1300], + entities: {}, + change: { ratio: 0.083, amount: 100 }, + }, + { + id: 'service-three', + aggregation: [600, 200], + entities: {}, + change: { ratio: -0.666, amount: -400 }, + }, + ], + }, }; return ( @@ -133,7 +135,7 @@ export const ProjectGrowthInstructionsPage = () => { Next, evaluate whether the growth is significant. This helps avoid premature optimization, where cost in engineering time is more than - would be saved from the optimization over a reasonable timeframe. + would be saved from the optimization over a reasonable time frame. We recommend reframing the cost growth itself in terms of engineering @@ -203,7 +205,7 @@ export const ProjectGrowthInstructionsPage = () => { Is the workload using cloud resources efficiently? For compute resources, do the utilization metrics look reasonable? Autoscaling infrastructure, such as Kubernetes, can run workloads more efficiently - without comprimising reliability. + without compromising reliability. Lifecycle diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index ee4641a4eb..bc34eb9c8d 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -31,7 +31,7 @@ export const ProjectSelect = ({ }: ProjectSelectProps) => { const classes = useStyles(); - const projectOptions = [{ id: 'all' } as Project, ...projects] + const projectOptions = projects .filter(p => p.id) .sort((a, b) => (a.id as string).localeCompare(b.id as string)); @@ -57,7 +57,7 @@ export const ProjectSelect = ({ onChange={handleOnChange} data-testid="project-filter-select" > - {projectOptions.map(proj => ( + {[{ id: 'all' }, ...projectOptions].map(proj => ( { const classes = useStyles(); - const projects = pluralOf(alert.projects.length, 'project'); + const projects = pluralize('project', alert.projects.length, true); const subheader = ` - Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days. + Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days. `; const options = { previousName: 'Unlabeled Cost', @@ -49,8 +49,8 @@ export const UnlabeledDataflowAlertCard = ({ 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..47b4dc80cb --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 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) => { + if (other) { + setOther(null); + } + 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 28bdb1a8b3..0ae9d05b09 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -14,12 +14,17 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + costInsightsPlugin, + costInsightsPlugin as plugin, + CostInsightsPage, + CostInsightsProjectGrowthInstructionsPage, + CostInsightsLabelDataflowInstructionsPage, +} 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/duration'; -export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/plugin.test.ts b/plugins/cost-insights/src/plugin.test.ts index 6210e11d4c..87be3ff8a2 100644 --- a/plugins/cost-insights/src/plugin.test.ts +++ b/plugins/cost-insights/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { costInsightsPlugin } from './plugin'; describe('cost-insights', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(costInsightsPlugin).toBeDefined(); }); }); diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts index 58465f6711..29e345532d 100644 --- a/plugins/cost-insights/src/plugin.ts +++ b/plugins/cost-insights/src/plugin.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -import { createPlugin, createRouteRef, PluginConfig } from '@backstage/core'; -import { CostInsightsPage } from './components/CostInsightsPage'; -import { ProjectGrowthInstructionsPage } from './components/ProjectGrowthInstructionsPage'; -import { LabelDataflowInstructionsPage } from './components/LabelDataflowInstructionsPage'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core'; +import { CostInsightsPage as CostInsightsPageComponent } from './components/CostInsightsPage'; +import { ProjectGrowthInstructionsPage as ProjectGrowthInstructionsPageComponent } from './components/ProjectGrowthInstructionsPage'; +import { LabelDataflowInstructionsPage as LabelDataflowInstructionsPageComponent } from './components/LabelDataflowInstructionsPage'; export const rootRouteRef = createRouteRef({ path: '/cost-insights', @@ -34,14 +38,51 @@ export const unlabeledDataflowAlertRef = createRouteRef({ title: 'Labeling Dataflow Jobs', }); -export const pluginConfig: PluginConfig = { +export const costInsightsPlugin = createPlugin({ id: 'cost-insights', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, CostInsightsPage); - router.addRoute(projectGrowthAlertRef, ProjectGrowthInstructionsPage); - router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); + router.addRoute(rootRouteRef, CostInsightsPageComponent); + router.addRoute( + projectGrowthAlertRef, + ProjectGrowthInstructionsPageComponent, + ); + router.addRoute( + unlabeledDataflowAlertRef, + LabelDataflowInstructionsPageComponent, + ); featureFlags.register('cost-insights-currencies'); }, -}; + routes: { + root: rootRouteRef, + growthAlerts: projectGrowthAlertRef, + unlabeledDataflowAlerts: unlabeledDataflowAlertRef, + }, +}); -export const plugin = createPlugin(pluginConfig); +export const CostInsightsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CostInsightsPage').then(m => m.CostInsightsPage), + mountPoint: rootRouteRef, + }), +); + +export const CostInsightsProjectGrowthInstructionsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ProjectGrowthInstructionsPage').then( + m => m.ProjectGrowthInstructionsPage, + ), + mountPoint: projectGrowthAlertRef, + }), +); + +export const CostInsightsLabelDataflowInstructionsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/LabelDataflowInstructionsPage').then( + m => m.LabelDataflowInstructionsPage, + ), + mountPoint: unlabeledDataflowAlertRef, + }), +); diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index c5c0463d99..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; - url: 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 acf707dd1e..e25f38e63d 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -15,15 +15,15 @@ */ /** - * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean - * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if - * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P3M to mean + * 'last completed quarter', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P3M represents costs for Q2 and P30D represents August 16 - * September 15. */ export enum Duration { + P7D = 'P7D', P30D = 'P30D', P90D = 'P90D', - P1M = 'P1M', P3M = 'P3M', } diff --git a/plugins/cost-insights/src/types/Entity.ts b/plugins/cost-insights/src/types/Entity.ts index 52f6271bb4..b49bb596ae 100644 --- a/plugins/cost-insights/src/types/Entity.ts +++ b/plugins/cost-insights/src/types/Entity.ts @@ -20,9 +20,8 @@ import { Maybe } from './Maybe'; export interface Entity { id: Maybe; aggregation: [number, number]; - entities: Entity[]; + entities: Record; change: ChangeStatistic; - entitiesLabel?: string; } /* @@ -31,8 +30,15 @@ export interface Entity { An entity could be atomic or composite. An atomic entity is indivisible and cannot be broken into sub-entities. - A composite entity can be broken down recursively into sub-entities - that generate cost **over the same time period**. All costs must sum to the root cost. + A composite entity is divided into sub-entities that account for portions + of the total cost **over the same time period**. The root entity is + expected to only have _one_ Record consisting of the sub-entities to display + in the product panel (keyed by the entity type, such as "service" for + compute entities). + + The root sub-entities may have multiple breakdowns - for example, a + breakdown of an entity cost by SKU vs deployment environment. The sum + aggregated cost of each keyed breakdown should equal the sub-entity's cost. Entities with null ids are considered "unlabeled" - costs without attribution. If an entity is a composite, it may only have one (1) null child but may have any number of @@ -45,44 +51,68 @@ export interface Entity { ratio: 2000, amount: 200 }, - entities: [ - { - id: 'service-a', - aggregation: [0, 100], - change: { - ratio: 100, - amount: 100 - }, - entities: [] - }, - { - id: 'service-b', - aggregation: [0, 100], - change: { - ratio: 100, - amount: 100 - }, - entities: [ - { - id: 'service-b-sku-a', - aggregation: [0, 25], - change: { - ratio: 25, - amount: 25 - }, - entities: [] + entities: { + service: [ + { + id: 'service-a', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 }, - { - id: null, // Unlabeled cost for service-b - aggregation: [0, 75], - change: { - ratio: 75, - amount: 75 - }, - entities: [] + entities: {} + }, + { + id: 'service-b', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 }, - ] - }, - ] + entities: { + SKU: [ + { + id: 'service-b-sku-a', + aggregation: [0, 25], + change: { + ratio: 25, + amount: 25 + }, + entities: {} + }, + { + id: null, // Unlabeled cost for service-b + aggregation: [0, 75], + change: { + ratio: 75, + amount: 75 + }, + entities: {} + }, + ], + deployment: [ + { + id: 'service-b-env-a', + aggregation: [0, 50], + change: { + ratio: 50, + amount: 50 + }, + entities: {} + }, + { + id: 'service-b-env-b', + aggregation: [0, 50], + change: { + ratio: 50, + amount: 50 + }, + entities: {} + }, + ] + } + }, + ] + } } */ 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/assert.ts b/plugins/cost-insights/src/utils/assert.ts index 19ddabe574..05ce65197b 100644 --- a/plugins/cost-insights/src/utils/assert.ts +++ b/plugins/cost-insights/src/utils/assert.ts @@ -50,3 +50,9 @@ export function findAlways( ): T { return assertAlways(collection.find(callback)); } + +export function findAnyKey( + record: Record | undefined, +): string | undefined { + return Object.keys(record ?? {}).find(_ => true); +} diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index f9e8cc4d6d..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.P1M, - exclusiveEndDate, + Duration.P30D, + 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 663a29a112..8adbc4dcaa 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -18,9 +18,9 @@ import { assertNever } from '../utils/assert'; export const rateOf = (cost: number, duration: Duration) => { switch (duration) { - case Duration.P1M: case Duration.P30D: return cost / 12; + case Duration.P7D: case Duration.P90D: case Duration.P3M: return cost / 4; diff --git a/plugins/cost-insights/src/utils/duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts index a5509eda07..47769a45f1 100644 --- a/plugins/cost-insights/src/utils/duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -15,7 +15,11 @@ */ import { Duration } from '../types'; -import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; +import { + inclusiveEndDateOf, + inclusiveStartDateOf, + quarterEndDate, +} from './duration'; const lastCompleteBillingDate = '2020-06-05'; @@ -23,7 +27,6 @@ describe.each` duration | startDate | endDate ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} - ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { @@ -33,3 +36,14 @@ describe.each` expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); + +describe.each` + inclusiveEndDate | expectedQuarterEndDate + ${'2020-12-31'} | ${'2020-12-31'} + ${'2020-12-30'} | ${'2020-09-30'} + ${'2021-02-19'} | ${'2020-12-31'} +`('quarterEndDate', ({ inclusiveEndDate, expectedQuarterEndDate }) => { + it(`calculates quarter end date correctly from inclusive end date ${inclusiveEndDate}`, () => { + expect(quarterEndDate(inclusiveEndDate)).toBe(expectedQuarterEndDate); + }); +}); diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 810160c7b6..27447537cc 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -24,28 +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 endDate 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() - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment(exclusiveEndDate) - .utc() - .startOf('month') + 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); @@ -59,21 +52,13 @@ 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); - case Duration.P1M: - return moment(inclusiveEndDate) - .utc() - .startOf('month') - .format(DEFAULT_DATE_FORMAT); + return moment(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(inclusiveEndDate) - .utc() - .startOf('quarter') + return moment(quarterEndDate(inclusiveEndDate)) + .add(1, 'day') .format(DEFAULT_DATE_FORMAT); default: return assertNever(duration); @@ -85,12 +70,30 @@ 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); + const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); + if (endOfQuarter === inclusiveEndDate) { + return endDate.format(DEFAULT_DATE_FORMAT); + } + return endDate + .startOf('quarter') + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); } diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index db25b6f3b7..a403f1ebba 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -57,8 +57,6 @@ describe('date formatters', () => { describe.each` duration | date | isEndDate | output - ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} - ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 9c3b2d643e..8f2b94f997 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -15,9 +15,9 @@ */ import moment from 'moment'; -import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import pluralize from 'pluralize'; +import { Duration } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; -import { pluralOf } from '../utils/grammar'; export type Period = { periodStart: string; @@ -75,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string { const n = Math.round(amount); const numString = numberFormatter.format(n); - return currency ? `${numString} ${pluralOf(n, currency)}` : numString; + return currency ? `${numString} ${pluralize(currency, n)}` : numString; } export function formatPercent(n: number): string { @@ -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', @@ -104,19 +101,6 @@ export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { return `${start} vs ${end}`; } -export function formatLastTwoMonths(inclusiveEndDate: string) { - const exclusiveEndDate = moment(inclusiveEndDate) - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); - const start = moment(inclusiveStartDateOf(Duration.P1M, exclusiveEndDate)) - .utc() - .format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M, inclusiveEndDate)) - .utc() - .format('MMMM'); - return `${start} vs ${end}`; -} - const formatRelativePeriod = ( duration: Duration, date: string, @@ -137,12 +121,6 @@ export function formatPeriod( isEndDate: boolean, ) { switch (duration) { - case Duration.P1M: - return monthOf( - isEndDate - ? inclusiveEndDateOf(duration, date) - : inclusiveStartDateOf(duration, date), - ); case Duration.P3M: return quarterOf( isEndDate diff --git a/plugins/cost-insights/src/utils/grammar.ts b/plugins/cost-insights/src/utils/grammar.ts index 653c567d0a..b82520dd53 100644 --- a/plugins/cost-insights/src/utils/grammar.ts +++ b/plugins/cost-insights/src/utils/grammar.ts @@ -22,20 +22,6 @@ const vowels = { u: 'U', }; -export const pluralOf = ( - n: number, - string: string, - plural?: string, -): string => { - if (n !== 1) { - if (plural) { - return plural; - } - return string.concat('s'); - } - return string; -}; - export const indefiniteArticleOf = ( articles: [string, string], word: string, 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 76bdf5f08f..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; @@ -52,7 +52,7 @@ export const createMockEntity = ( const defaultEntity: Entity = { id: 'test-entity', aggregation: [100, 200], - entities: [], + entities: {}, change: { ratio: 0, amount: 0, @@ -143,7 +143,7 @@ export const MockProductTypes: Record = { export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, -).map(productType => ({ duration: Duration.P1M, productType })); +).map(productType => ({ duration: Duration.P30D, productType })); export const MockProducts: Product[] = Object.keys(MockProductTypes).map( productType => @@ -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,19 +228,30 @@ 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', ); + function nextDelta(): number { + const varianceFromBaseline = 0.15; + // Let's give positive vibes in trendlines - higher change for positive delta with >0.5 value + const positiveTrendChance = 0.55; + const normalization = positiveTrendChance - 1; + return baseline * (Math.random() + normalization) * varianceFromBaseline; + } + 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, inclusiveEndDate)) + .add(i, 'day') + .format(DEFAULT_DATE_FORMAT); + const amount = Math.max(0, last + nextDelta()); values.push({ - date: dayjs(inclusiveStartDateOf(duration, endDate)) - .add(i, 'day') - .format(DEFAULT_DATE_FORMAT), - amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), + date: date, + amount: amount, }); return values; }, @@ -517,35 +527,37 @@ export const SampleBigQueryInsights: Entity = { ratio: 3, amount: 20_000, }, - entities: [ - { - id: 'entity-a', - aggregation: [5_000, 10_000], - change: { - ratio: 1, - amount: 5_000, + entities: { + dataset: [ + { + id: 'dataset-a', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, }, - entities: [], - }, - { - id: 'entity-b', - aggregation: [5_000, 10_000], - change: { - ratio: 1, - amount: 5_000, + { + id: 'dataset-b', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, }, - entities: [], - }, - { - id: 'entity-c', - aggregation: [0, 10_000], - change: { - ratio: 10_000, - amount: 10_000, + { + id: 'dataset-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, + }, + entities: {}, }, - entities: [], - }, - ], + ], + }, }; export const SampleCloudDataflowInsights: Entity = { @@ -555,110 +567,118 @@ export const SampleCloudDataflowInsights: Entity = { ratio: 0.58, amount: 58_000, }, - entities: [ - { - id: null, - aggregation: [10_000, 12_000], - change: { - ratio: 0.2, - amount: 2_000, + entities: { + pipeline: [ + { + id: null, + aggregation: [10_000, 12_000], + change: { + ratio: 0.2, + amount: 2_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [3_000, 4_000], + change: { + ratio: 0.333333, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [7_000, 8_000], + change: { + ratio: 0.14285714, + amount: 1_000, + }, + entities: {}, + }, + ], + }, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [3_000, 4_000], - change: { - ratio: 0.333333, - amount: 1_000, - }, - entities: [], + { + id: 'pipeline-a', + aggregation: [60_000, 70_000], + change: { + ratio: 0.16666666666666666, + amount: 10_000, }, - { - id: 'Sample SKU B', - aggregation: [7_000, 8_000], - change: { - ratio: 0.14285714, - amount: 1_000, - }, - entities: [], + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [20_000, 15_000], + change: { + ratio: -0.25, + amount: -5_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [30_000, 35_000], + change: { + ratio: -0.16666666666666666, + amount: -5_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [10_000, 20_000], + change: { + ratio: 1, + amount: 10_000, + }, + entities: {}, + }, + ], }, - ], - }, - { - id: 'entity-a', - aggregation: [60_000, 70_000], - change: { - ratio: 0.16666666666666666, - amount: 10_000, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [20_000, 15_000], - change: { - ratio: -0.25, - amount: -5_000, - }, - entities: [], + { + id: 'pipeline-b', + aggregation: [12_000, 8_000], + change: { + ratio: -0.33333, + amount: -4_000, }, - { - id: 'Sample SKU B', - aggregation: [30_000, 35_000], - change: { - ratio: -0.16666666666666666, - amount: -5_000, - }, - entities: [], + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [4_000, 4_000], + change: { + ratio: 0, + amount: 0, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [8_000, 4_000], + change: { + ratio: -0.5, + amount: -4_000, + }, + entities: {}, + }, + ], }, - { - id: 'Sample SKU C', - aggregation: [10_000, 20_000], - change: { - ratio: 1, - amount: 10_000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-b', - aggregation: [12_000, 8_000], - change: { - ratio: -0.33333, - amount: -4_000, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [4_000, 4_000], - change: { - ratio: 0, - amount: 0, - }, - entities: [], + { + id: 'pipeline-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, }, - { - id: 'Sample SKU B', - aggregation: [8_000, 4_000], - change: { - ratio: -0.5, - amount: -4_000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-c', - aggregation: [0, 10_000], - change: { - ratio: 10_000, - amount: 10_000, + entities: {}, }, - entities: [], - }, - ], + ], + }, }; export const SampleCloudStorageInsights: Entity = { @@ -668,91 +688,97 @@ export const SampleCloudStorageInsights: Entity = { ratio: 0, amount: 0, }, - entities: [ - { - id: 'entity-a', - aggregation: [15_000, 20_000], - change: { - ratio: 0.333, - amount: 5_000, + entities: { + bucket: [ + { + id: 'bucket-a', + aggregation: [15_000, 20_000], + change: { + ratio: 0.333, + amount: 5_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [10_000, 11_000], + change: { + ratio: 0.1, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [2_000, 5_000], + change: { + ratio: 1.5, + amount: 3_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [3_000, 4_000], + change: { + ratio: 0.3333, + amount: 1_000, + }, + entities: {}, + }, + ], + }, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [10_000, 11_000], - change: { - ratio: 0.1, - amount: 1_000, - }, - entities: [], + { + id: 'bucket-b', + aggregation: [30_000, 25_000], + change: { + ratio: -0.16666, + amount: -5_000, }, - { - id: 'Sample SKU B', - aggregation: [2_000, 5_000], - change: { - ratio: 1.5, - amount: 3_000, - }, - entities: [], + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [12_000, 13_000], + change: { + ratio: 0.08333333333333333, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [16_000, 12_000], + change: { + ratio: -0.25, + amount: -4_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [2_000, 0], + change: { + ratio: -1, + amount: -2000, + }, + entities: {}, + }, + ], }, - { - id: 'Sample SKU C', - aggregation: [3_000, 4_000], - change: { - ratio: 0.3333, - amount: 1_000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-b', - aggregation: [30_000, 25_000], - change: { - ratio: -0.16666, - amount: -5_000, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [12_000, 13_000], - change: { - ratio: 0.08333333333333333, - amount: 1_000, - }, - entities: [], + { + id: 'bucket-c', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0, }, - { - id: 'Sample SKU B', - aggregation: [16_000, 12_000], - change: { - ratio: -0.25, - amount: -4_000, - }, - entities: [], - }, - { - id: 'Sample SKU C', - aggregation: [2_000, 0], - change: { - ratio: -1, - amount: -2000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-c', - aggregation: [0, 0], - change: { - ratio: 0, - amount: 0, + entities: {}, }, - entities: [], - }, - ], + ], + }, }; export const SampleComputeEngineInsights: Entity = { @@ -762,91 +788,137 @@ export const SampleComputeEngineInsights: Entity = { ratio: 0.125, amount: 10_000, }, - entities: [ - { - id: 'entity-a', - aggregation: [20_000, 10_000], - change: { - ratio: -0.5, - amount: -10_000, + entities: { + service: [ + { + id: 'service-a', + aggregation: [20_000, 10_000], + change: { + ratio: -0.5, + amount: -10_000, + }, + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [4_000, 2_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [7_000, 6_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [9_000, 2_000], + change: { + ratio: -0.7777777777777778, + amount: -7000, + }, + entities: {}, + }, + ], + deployment: [ + { + id: 'Compute Engine', + aggregation: [7_000, 6_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Kubernetes', + aggregation: [4_000, 2_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + ], + }, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [4_000, 2_000], - change: { - ratio: -0.5, - amount: -2_000, - }, - entities: [], + { + id: 'service-b', + aggregation: [10_000, 20_000], + change: { + ratio: 1, + amount: 10_000, }, - { - id: 'Sample SKU B', - aggregation: [7_000, 6_000], - change: { - ratio: -0.14285714285714285, - amount: -1_000, - }, - entities: [], + entities: { + SKU: [ + { + id: 'Sample SKU A', + aggregation: [1_000, 2_000], + change: { + ratio: 1, + amount: 1_000, + }, + entities: {}, + }, + { + id: 'Sample SKU B', + aggregation: [4_000, 8_000], + change: { + ratio: 1, + amount: 4_000, + }, + entities: {}, + }, + { + id: 'Sample SKU C', + aggregation: [5_000, 10_000], + change: { + ratio: 1, + amount: 5_000, + }, + entities: {}, + }, + ], + deployment: [ + { + id: 'Compute Engine', + aggregation: [7_000, 6_000], + change: { + ratio: -0.5, + amount: -2_000, + }, + entities: {}, + }, + { + id: 'Kubernetes', + aggregation: [4_000, 2_000], + change: { + ratio: -0.14285714285714285, + amount: -1_000, + }, + entities: {}, + }, + ], }, - { - id: 'Sample SKU C', - aggregation: [9_000, 2_000], - change: { - ratio: -0.7777777777777778, - amount: -7000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-b', - aggregation: [10_000, 20_000], - change: { - ratio: 1, - amount: 10_000, }, - entities: [ - { - id: 'Sample SKU A', - aggregation: [1_000, 2_000], - change: { - ratio: 1, - amount: 1_000, - }, - entities: [], + { + id: 'service-c', + aggregation: [0, 10_000], + change: { + ratio: 10_000, + amount: 10_000, }, - { - id: 'Sample SKU B', - aggregation: [4_000, 8_000], - change: { - ratio: 1, - amount: 4_000, - }, - entities: [], - }, - { - id: 'Sample SKU C', - aggregation: [5_000, 10_000], - change: { - ratio: 1, - amount: 5_000, - }, - entities: [], - }, - ], - }, - { - id: 'entity-c', - aggregation: [0, 10_000], - change: { - ratio: 10_000, - amount: 10_000, + entities: {}, }, - entities: [], - }, - ], + ], + }, }; export const SampleEventsInsights: Entity = { @@ -856,83 +928,88 @@ export const SampleEventsInsights: Entity = { ratio: -0.5, amount: -10_000, }, - entitiesLabel: 'Product', - entities: [ - { - id: 'entity-a', - aggregation: [15_000, 7_000], - change: { - ratio: -0.53333333333, - amount: -8_000, + entities: { + event: [ + { + id: 'event-a', + aggregation: [15_000, 7_000], + change: { + ratio: -0.53333333333, + amount: -8_000, + }, + entities: { + product: [ + { + id: 'Sample Product A', + aggregation: [5_000, 2_000], + change: { + ratio: -0.6, + amount: -3_000, + }, + entities: {}, + }, + { + id: 'Sample Product B', + aggregation: [7_000, 2_500], + change: { + ratio: -0.64285714285, + amount: -4_500, + }, + entities: {}, + }, + { + id: 'Sample Product C', + aggregation: [3_000, 2_500], + change: { + ratio: -0.16666666666, + amount: -500, + }, + entities: {}, + }, + ], + }, }, - entities: [ - { - id: 'Sample Product A', - aggregation: [5_000, 2_000], - change: { - ratio: -0.6, - amount: -3_000, - }, - entities: [], + { + id: 'event-b', + aggregation: [5_000, 3_000], + change: { + ratio: -0.4, + amount: -2_000, }, - { - id: 'Sample Product B', - aggregation: [7_000, 2_500], - change: { - ratio: -0.64285714285, - amount: -4_500, - }, - entities: [], + entities: { + product: [ + { + id: 'Sample Product A', + aggregation: [2_000, 1_000], + change: { + ratio: -0.5, + amount: -1_000, + }, + entities: {}, + }, + { + id: 'Sample Product B', + aggregation: [1_000, 1_500], + change: { + ratio: 0.5, + amount: 500, + }, + entities: {}, + }, + { + id: 'Sample Product C', + aggregation: [2_000, 500], + change: { + ratio: -0.75, + amount: -1_500, + }, + entities: {}, + }, + ], }, - { - id: 'Sample Product C', - aggregation: [3_000, 2_500], - change: { - ratio: -0.16666666666, - amount: -500, - }, - entities: [], - }, - ], - }, - { - id: 'entity-b', - aggregation: [5_000, 3_000], - change: { - ratio: -0.4, - amount: -2_000, }, - entities: [ - { - id: 'Sample Product A', - aggregation: [2_000, 1_000], - change: { - ratio: -0.5, - amount: -1_000, - }, - entities: [], - }, - { - id: 'Sample Product B', - aggregation: [1_000, 1_500], - change: { - ratio: 0.5, - amount: 500, - }, - entities: [], - }, - { - id: 'Sample Product C', - aggregation: [2_000, 500], - change: { - ratio: -0.75, - amount: -1_500, - }, - entities: [], - }, - ], - }, - ], + ], + }, }; export function entityOf(product: string): Entity { @@ -988,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..4979882ed3 --- /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": "^11.2.5", + "@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/techdocs-backend/src/techdocs/index.ts b/plugins/explore-react/src/index.ts similarity index 95% rename from plugins/techdocs-backend/src/techdocs/index.ts rename to plugins/explore-react/src/index.ts index 7113525bb8..d46f5d4f27 100644 --- a/plugins/techdocs-backend/src/techdocs/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 * from './stages'; + +export * from './tools'; diff --git a/plugins/explore-react/src/setupTests.ts b/plugins/explore-react/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/explore-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/packages/backend/src/plugins/sentry.ts b/plugins/explore-react/src/tools/api.test.ts similarity index 70% rename from packages/backend/src/plugins/sentry.ts rename to plugins/explore-react/src/tools/api.test.ts index 5cd0e55761..386d66ead2 100644 --- a/packages/backend/src/plugins/sentry.ts +++ b/plugins/explore-react/src/tools/api.test.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { exploreToolsConfigRef } from './api'; -import { createRouter } from '@backstage/plugin-sentry-backend'; -import type { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter(logger); -} +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 e4072fd0f2..be284dfa64 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,106 @@ # @backstage/plugin-explore +## 0.2.7 + +### Patch Changes + +- 347137ccf: Display the owner of a domain on the domain card. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.5 + +### Patch Changes + +- 0fe8ff5be: Catch catalog errors and display to user +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.1 ### 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 45cbb310f8..dfd68207f2 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.2.1", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,23 +30,26 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.0", + "@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.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", 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..e5efc543b2 --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx @@ -0,0 +1,54 @@ +/* + * 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, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { ItemCard } from '@backstage/core'; +import { + EntityRefLinks, + entityRoute, + entityRouteParams, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { generatePath } from 'react-router-dom'; + +type DomainCardProps = { + entity: DomainEntity; +}; + +export const DomainCard = ({ entity }: DomainCardProps) => { + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return ( + + } + label="Explore" + // TODO: Use useRouteRef here to generate the path + href={generatePath( + `/catalog/${entityRoute.path}`, + entityRouteParams(entity), + )} + /> + ); +}; 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..f29ad2cd6a --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DomainEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +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 } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => + expect(getByText('No domains to display')).toBeInTheDocument(), + ); + }); + + it('renders a friendly error if it cannot collect domains', async () => { + const catalogError = new Error('Network timeout'); + catalogApi.getEntities.mockRejectedValueOnce(catalogError); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => + expect(getByText(/Could not load domains/)).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..182adf70ea --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -0,0 +1,73 @@ +/* + * 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, + WarningPanel, +} 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, error } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'domain' }, + }); + + return response.items as DomainEntity[]; + }, [catalogApi]); + + return ( + + + Discover the domains in your ecosystem. + + + {loading && } + {error && ( + + {error.message} + + )} + {!loading && !error && (!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 90% rename from plugins/explore/src/components/ExploreCard.tsx rename to plugins/explore/src/components/ToolCard/ToolCard.tsx index 9c61e9bea8..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, { FC } 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: FC = ({ card, objectFit }) => { +export const ToolCard = ({ card, objectFit }: Props) => { const classes = useStyles(); const { title, description, url, image, lifecycle, newsTag, tags } = card; @@ -130,5 +123,3 @@ const ExploreCard: FC = ({ card, objectFit }) => { ); }; - -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..125a72eb12 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + ExploreTool, + exploreToolsConfigRef, +} from '@backstage/plugin-explore-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +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 } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText('Lighthouse')).toBeInTheDocument(); + expect(getByText('Tech Radar')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = await renderInTestApp( + + + , + ); + + 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/.eslintrc.js b/plugins/fossa/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/fossa/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md new file mode 100644 index 0000000000..23129f83f9 --- /dev/null +++ b/plugins/fossa/CHANGELOG.md @@ -0,0 +1,97 @@ +# @backstage/plugin-fossa + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.1 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 7afdfef98: Bump dependency versions of @backstage/core, cli and test-utils +- Updated dependencies [a08c32ced] + - @backstage/core@0.4.3 diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md new file mode 100644 index 0000000000..6e53ced0cf --- /dev/null +++ b/plugins/fossa/README.md @@ -0,0 +1,79 @@ +# FOSSA Plugin + +The FOSSA Plugin displays code statistics from [FOSSA](https://fossa.com/). + +![FOSSA Card](./docs/fossa-card.png) + +## Getting Started + +1. Install the FOSSA Plugin: + +```bash +# packages/app + +yarn add @backstage/plugin-fossa +``` + +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { fossaPlugin } from '@backstage/plugin-fossa'; +``` + +3. Add the `EntityFossaCard` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { EntityFossaCard } from '@backstage/plugin-fossa'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +4. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/fossa': + target: https://app.fossa.io/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'token ' + $env: FOSSA_AUTH_HEADER + +# if you have a fossa organization, configure your id here +fossa: + organizationId: +``` + +5. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) + +6. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + fossa.io/project-name: YOUR_PROJECT_NAME +spec: + type: library + owner: CNCF + lifecycle: experimental +``` diff --git a/plugins/sentry/src/data/sentry-api.ts b/plugins/fossa/config.d.ts similarity index 79% rename from plugins/sentry/src/data/sentry-api.ts rename to plugins/fossa/config.d.ts index 538900b738..744a1cb52d 100644 --- a/plugins/sentry/src/data/sentry-api.ts +++ b/plugins/fossa/config.d.ts @@ -13,8 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryIssue } from './sentry-issue'; -export interface SentryApi { - fetchIssues(project: string, statsFor: string): Promise; +export interface Config { + fossa?: { + /** + * The organization id in fossa. + * @visibility frontend + */ + organizationId: string; + }; } diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx new file mode 100644 index 0000000000..06c71c48b7 --- /dev/null +++ b/plugins/fossa/dev/index.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { 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, + deps: {}, + factory: () => + ({ + getFindingSummary: async projectTitle => { + switch (projectTitle) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'zero-deps': + return { + timestamp: new Date().toISOString(), + issueCount: 0, + dependencyCount: 0, + projectDefaultBranch: 'master', + projectUrl: `/#${projectTitle}`, + }; + + case 'issues': + return { + timestamp: new Date().toISOString(), + issueCount: 5, + dependencyCount: 100, + projectDefaultBranch: 'develop', + projectUrl: `/#${projectTitle}`, + }; + + case 'no-issues': + return { + timestamp: new Date().toISOString(), + issueCount: 0, + dependencyCount: 150, + projectDefaultBranch: 'feat/fossa', + projectUrl: `/#${projectTitle}`, + }; + + default: + return undefined; + } + }, + } as FossaApi), + }) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) + .render(); diff --git a/plugins/fossa/docs/fossa-card.png b/plugins/fossa/docs/fossa-card.png new file mode 100644 index 0000000000..227d847029 Binary files /dev/null and b/plugins/fossa/docs/fossa-card.png differ diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json new file mode 100644 index 0000000000..0d28f2285b --- /dev/null +++ b/plugins/fossa/package.json @@ -0,0 +1,63 @@ +{ + "name": "@backstage/plugin-fossa", + "version": "0.2.3", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/fossa" + }, + "keywords": [ + "backstage", + "fossa" + ], + "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.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", + "cross-fetch": "^3.0.6", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@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", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts new file mode 100644 index 0000000000..84406e1b1a --- /dev/null +++ b/plugins/fossa/src/api/FossaApi.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; + +export interface FindingSummary { + timestamp: string; + issueCount: number; + dependencyCount: number; + projectDefaultBranch: string; + projectUrl: string; +} + +export const fossaApiRef = createApiRef({ + id: 'plugin.fossa.service', + description: 'Used by the Fossa plugin to make requests', +}); + +export type FossaApi = { + getFindingSummary(projectTitle: string): Promise; +}; diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts new file mode 100644 index 0000000000..820210e2b6 --- /dev/null +++ b/plugins/fossa/src/api/FossaClient.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UrlPatternDiscovery, IdentityApi } from '@backstage/core'; +import { msw } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { FindingSummary, FossaApi, FossaClient } from './index'; + +const server = setupServer(); + +const identityApi: IdentityApi = { + getUserId() { + return 'jane-fonda'; + }, + getProfile() { + return { email: 'jane-fonda@spotify.com' }; + }, + async getIdToken() { + return Promise.resolve('fake-id-token'); + }, + async signOut() { + return Promise.resolve(); + }, +}; + +describe('FossaClient', () => { + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + let client: FossaApi; + + beforeEach(() => { + client = new FossaClient({ + discoveryApi, + identityApi, + organizationId: '8736', + }); + }); + + it('should report finding summary', 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([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should report finding summary without licensing_issue_count', 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([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'refs/master', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 100, + dependencyCount: 160, + projectDefaultBranch: 'refs/master', + projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } 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, identityApi }); + + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&sort=title+&title=our-service', + ); + return res(ctx.status(404)); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should handle 404 status', 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.status(404)); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); +}); diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts new file mode 100644 index 0000000000..5693895901 --- /dev/null +++ b/plugins/fossa/src/api/FossaClient.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { FindingSummary, FossaApi } from './FossaApi'; + +export class FossaClient implements FossaApi { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + organizationId?: string; + + constructor({ + discoveryApi, + identityApi, + organizationId, + }: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + organizationId?: string; + }) { + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + this.organizationId = organizationId; + } + + private async callApi(path: string): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`; + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(`${apiUrl}/${path}`, { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); + if (response.status === 200) { + return await response.json(); + } + return undefined; + } + + async getFindingSummary( + projectTitle: string, + ): Promise { + const project = await this.callApi( + `projects?count=1&sort=title+&title=${projectTitle}${ + this.organizationId ? `&organizationId=${this.organizationId}` : '' + }`, + ); + + if (!project || project.length === 0 || project[0].title !== projectTitle) { + return undefined; + } + + const revision = project[0].revisions[0]; + return { + timestamp: revision.updatedAt, + issueCount: + revision.unresolved_licensing_issue_count || + revision.unresolved_issue_count, + dependencyCount: revision.dependency_count, + projectDefaultBranch: project[0].default_branch, + projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( + project[0].locator, + )}`, + }; + } +} diff --git a/plugins/fossa/src/api/index.ts b/plugins/fossa/src/api/index.ts new file mode 100644 index 0000000000..f31c4bd1d5 --- /dev/null +++ b/plugins/fossa/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { FossaApi, FindingSummary } from './FossaApi'; +export { fossaApiRef } from './FossaApi'; +export { FossaClient } from './FossaClient'; diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx new file mode 100644 index 0000000000..4cc27f5d03 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EmptyState, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +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'; + +const useStyles = makeStyles(theme => ({ + numberError: { + fontSize: '5rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + margin: theme.spacing(2, 0), + color: theme.palette.error.main, + }, + numberSuccess: { + fontSize: '5rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + margin: theme.spacing(2, 0), + color: theme.palette.success.main, + }, + description: { + fontSize: '1rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + color: theme.palette.text.secondary, + }, + disabled: { + backgroundColor: theme.palette.background.default, + }, + lastAnalyzed: { + color: theme.palette.text.secondary, + textAlign: 'center', + }, + branch: { + textDecoration: 'underline dotted', + }, +})); + +export const FossaCard = () => { + const { entity } = useEntity(); + const fossaApi = useApi(fossaApiRef); + + const projectTitle = useProjectName(entity); + + const { value, loading } = useAsync( + async () => + projectTitle ? fossaApi.getFindingSummary(projectTitle) : undefined, + [fossaApi, projectTitle], + ); + + const deepLink = value + ? { + title: 'View more', + link: value.projectUrl, + } + : undefined; + + const classes = useStyles(); + + return ( + <> + + {loading && } + + {!loading && !projectTitle && ( + + )} + + {!loading && projectTitle && !value && ( + + )} + + {value && ( + + +

0 || value.dependencyCount === 0 + ? classes.numberError + : classes.numberSuccess + } + > + {value.issueCount} +

+ {value.dependencyCount > 0 && ( +

Number of issues

+ )} + {value.dependencyCount === 0 && ( +

+ No Dependencies. +
+ Please check your FOSSA project settings. +

+ )} +
+ + + Last analyzed on{' '} + {new Date(value.timestamp).toLocaleString('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + Based on {value.dependencyCount} Dependencies on branch{' '} + + + {value.projectDefaultBranch} + + + . + +
+ )} +
+ + ); +}; diff --git a/plugins/fossa/src/components/FossaCard/index.ts b/plugins/fossa/src/components/FossaCard/index.ts new file mode 100644 index 0000000000..5f84660bf0 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/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 { FossaCard } from './FossaCard'; diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/fossa/src/components/index.ts similarity index 93% rename from plugins/kubernetes/src/components/Services/index.ts rename to plugins/fossa/src/components/index.ts index d52ebf5f14..a49c71603b 100644 --- a/plugins/kubernetes/src/components/Services/index.ts +++ b/plugins/fossa/src/components/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 './FossaCard'; diff --git a/plugins/fossa/src/components/useProjectName.ts b/plugins/fossa/src/components/useProjectName.ts new file mode 100644 index 0000000000..88379ca3fa --- /dev/null +++ b/plugins/fossa/src/components/useProjectName.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 { Entity } from '@backstage/catalog-model'; + +export const FOSSA_PROJECT_NAME_ANNOTATION = 'fossa.io/project-name'; + +export const useProjectName = (entity: Entity): string | undefined => { + return ( + entity?.metadata.annotations?.[FOSSA_PROJECT_NAME_ANNOTATION] ?? undefined + ); +}; diff --git a/plugins/fossa/src/extensions.tsx b/plugins/fossa/src/extensions.tsx new file mode 100644 index 0000000000..38573c2d1f --- /dev/null +++ b/plugins/fossa/src/extensions.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createComponentExtension } from '@backstage/core'; +import { fossaPlugin } from './plugin'; + +export const EntityFossaCard = fossaPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/FossaCard').then(m => m.FossaCard), + }, + }), +); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts new file mode 100644 index 0000000000..f1b645c29b --- /dev/null +++ b/plugins/fossa/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { fossaPlugin } from './plugin'; +export { EntityFossaCard } from './extensions'; diff --git a/plugins/fossa/src/plugin.test.ts b/plugins/fossa/src/plugin.test.ts new file mode 100644 index 0000000000..9aed1387c5 --- /dev/null +++ b/plugins/fossa/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fossaPlugin } from './plugin'; + +describe('fossa', () => { + it('should export plugin', () => { + expect(fossaPlugin).toBeDefined(); + }); +}); diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts new file mode 100644 index 0000000000..638be4a0bf --- /dev/null +++ b/plugins/fossa/src/plugin.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 { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, + identityApiRef, +} from '@backstage/core'; +import { fossaApiRef, FossaClient } from './api'; + +export const fossaPlugin = createPlugin({ + id: 'fossa', + apis: [ + createApiFactory({ + api: fossaApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new FossaClient({ + discoveryApi, + identityApi, + organizationId: configApi.getOptionalString('fossa.organizationId'), + }), + }), + ], +}); diff --git a/plugins/fossa/src/setupTests.ts b/plugins/fossa/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/fossa/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index 6884ecceee..92f1da463e 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,41 @@ # @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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.1 ### 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 faa79ed647..10615878ed 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.1", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "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/gcp-projects" + }, + "keywords": [ + "backstage", + "google cloud" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,22 +30,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/api-docs/src/components/useComponentApiNames.ts b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx similarity index 55% rename from plugins/api-docs/src/components/useComponentApiNames.ts rename to plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx index 1303967895..158347a9ff 100644 --- a/plugins/api-docs/src/components/useComponentApiNames.ts +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx @@ -14,16 +14,16 @@ * limitations under the License. */ -import { - ComponentEntity, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; +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 useComponentApiNames = (entity: ComponentEntity) => { - // TODO: This code doesn't handle namespaces and kinds correctly, but will be removed soon - return ( - entity.relations - ?.filter(r => r.type === RELATION_PROVIDES_API) - ?.map(r => r.target.name) || [] - ); -}; +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 e43704f83a..ac717c1059 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,165 @@ # @backstage/plugin-github-actions +## 0.3.4 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.3 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [491f3a0ec] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/integration@0.5.0 + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.2 + +### Patch Changes + +- f5f45744e: 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`. +- Updated dependencies [ffffea8e6] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/integration@0.4.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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` +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + - @backstage/core@0.4.2 + - @backstage/plugin-catalog@0.2.8 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [7dd2ef7d1] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/core-api@0.2.6 + - @backstage/plugin-catalog@0.2.7 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.3 ### Patch Changes @@ -48,7 +208,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 5822a0e608..e377432db6 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.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,17 @@ "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/github-actions" + }, + "keywords": [ + "backstage", + "github", + "github actions" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,16 +32,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/core-api": "^0.2.4", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/core": "^0.6.3", + "@backstage/integration": "^0.5.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", - "@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", @@ -40,11 +50,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", 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..4ce667eb93 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -13,26 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; -import { WorkflowRunStatus } from '../WorkflowRunStatus'; import { - Link, - Theme, - makeStyles, - LinearProgress, - Typography, -} from '@material-ui/core'; -import { - InfoCard, - StructuredMetadataTable, + configApiRef, errorApiRef, + InfoCard, + InfoCardVariants, + StructuredMetadataTable, useApi, } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + LinearProgress, + Link, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import React, { useEffect } from 'react'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; const useStyles = makeStyles({ externalLinkIcon: { @@ -79,16 +83,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 +123,21 @@ export const LatestWorkflowRunCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; branch: string; - variant?: string; + variant?: InfoCardVariants; }; 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 46f60981a0..30807482a4 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -14,37 +14,53 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { errorApiRef, useApi } from '@backstage/core-api'; +import { + configApiRef, + EmptyState, + errorApiRef, + InfoCard, + InfoCardVariants, + 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; - variant?: string; + variant?: InfoCardVariants; }; 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, @@ -60,7 +76,7 @@ export const RecentWorkflowRunsCard = ({ +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..762561582c 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { Link } from '@backstage/core'; +import { configApiRef, Breadcrumbs, Link, useApi } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { Accordion, AccordionDetails, AccordionSummary, Box, - Breadcrumbs, CircularProgress, LinearProgress, Link as MaterialLink, @@ -71,26 +71,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 +138,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); @@ -178,10 +183,12 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { } return (
- - Workflow runs - Workflow run details - + + + Workflow runs + Workflow run details + +
@@ -209,8 +216,8 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { @@ -218,7 +225,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 390f35d516..fe1c774dad 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -30,13 +30,15 @@ import { import React, { Suspense } from 'react'; import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; -import LinePart from 'react-lazylog/build/LinePart'; 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')); const useStyles = makeStyles(() => ({ button: { @@ -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 fcde5fefbb..6a3b88b052 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Link, Typography, @@ -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; @@ -114,7 +121,7 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -export const WorkflowRunsTableView: FC = ({ +export const WorkflowRunsTableView = ({ projectName, loading, pageSize, @@ -124,7 +131,7 @@ export const WorkflowRunsTableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
{ + 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, @@ -177,7 +190,7 @@ export const WorkflowRunsTable = ({ (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 7aa4253a2e..bb0834a03d 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,48 @@ # @backstage/plugin-gitops-profiles +## 0.2.5 + +### Patch Changes + +- accdfeb30: Migrated to new composability API, exporting the plugin instance as `gitopsProfilesPlugin` and pages as `GitopsProfilesClusterListPage`, `GitopsProfilesClusterPage`, and `GitopsProfilesCreatePage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.1 ### 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 3adf7291db..fa149aa659 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.1", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/gitops-profiles" + }, + "keywords": [ + "backstage", + "gitops" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", @@ -32,11 +42,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index 70bf4c8c3f..ec689865c3 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { Content, ContentHeader, @@ -33,7 +33,7 @@ import { useAsync } from 'react-use'; import { gitOpsApiRef } from '../../api'; import { Alert } from '@material-ui/lab'; -const ClusterList: FC<{}> = () => { +const ClusterList = () => { const api = useApi(gitOpsApiRef); const githubAuth = useApi(githubAuthApiRef); const [githubUsername, setGithubUsername] = useState(String); diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index 9b6e5bdb08..0d304753c4 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Content, Header, @@ -30,7 +30,7 @@ import { useParams } from 'react-router-dom'; import { gitOpsApiRef, Status } from '../../api'; import { transformRunStatus } from '../ProfileCatalog'; -const ClusterPage: FC<{}> = () => { +const ClusterPage = () => { const params = useParams() as { owner: string; repo: string }; const [pollingLog, setPollingLog] = useState(true); diff --git a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx index 84b1c6b432..a3393a8ddb 100644 --- a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { Link } from '@material-ui/core'; import { ClusterStatus } from '../../api'; @@ -61,7 +61,7 @@ const columns: TableColumn[] = [ type ClusterTableProps = { components: ClusterStatus[]; }; -const ClusterTable: FC = ({ components }) => { +const ClusterTable = ({ components }: ClusterTableProps) => { return (
); diff --git a/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx b/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx index a73afbb04c..1921492cb4 100644 --- a/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTemplateCard/ClusterTemplateCard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import Card from '@material-ui/core/Card'; import CardHeader from '@material-ui/core/CardHeader'; @@ -62,7 +62,7 @@ interface Props { activeIndex: number; } -const ClusterTemplateCard: FC = props => { +const ClusterTemplateCard = (props: Props) => { const classes = useStyles(); const handleSelect = () => { diff --git a/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx b/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx index 20beb6d00a..e4089c61b5 100644 --- a/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTemplateCardList/ClusterTemplateCardList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import ClusterTemplateCard from '../ClusterTemplateCard'; @@ -27,7 +27,7 @@ interface Props { }[]; } -const ClusterTemplateCardList: FC = props => { +const ClusterTemplateCardList = (props: Props) => { const [activeIndex, setActiveIndex] = React.useState(-1); const handleClicked = (index: number, repository: string) => { diff --git a/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx b/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx index 1c4227a0b0..a06a6df9b8 100644 --- a/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCard/ProfileCard.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { Avatar, Card, @@ -66,7 +66,7 @@ interface Props { selections: Set; } -const ProfileCard: FC = props => { +const ProfileCard = (props: Props) => { const [selection, setSelection] = useState(false); const handleSelect = () => { diff --git a/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx b/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx index 5aea5e56e4..9e5a9c0c8f 100644 --- a/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCardList/ProfileCardList.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { Grid } from '@material-ui/core'; import ProfileCard from '../ProfileCard'; @@ -26,7 +26,7 @@ interface Props { }[]; } -const ProfileCardList: FC = props => { +const ProfileCardList = (props: Props) => { const [selections, setSelections] = useState>(new Set()); const [profiles, setProfiles] = useState>(new Set()); diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index f01ac70e64..046fa58600 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -14,23 +14,23 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; -import ProfileCatalog from './ProfileCatalog'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { ApiProvider, ApiRegistry, - githubAuthApiRef, GithubAuth, + githubAuthApiRef, OAuthRequestManager, UrlPatternDiscovery, } from '@backstage/core'; +import { renderInTestApp } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import React from 'react'; import { gitOpsApiRef, GitOpsRestApi } from '../../api'; +import ProfileCatalog from './ProfileCatalog'; describe('ProfileCatalog', () => { - it('should render', () => { + it('should render', async () => { const oauthRequestApi = new OAuthRequestManager(); const apis = ApiRegistry.from([ [gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')], @@ -44,15 +44,15 @@ describe('ProfileCatalog', () => { }), ], ]); - const rendered = render( + + const { getByText } = await renderInTestApp( , ); - expect( - rendered.getByText('Create GitOps-managed Cluster'), - ).toBeInTheDocument(); + + expect(getByText('Create GitOps-managed Cluster')).toBeInTheDocument(); }); }); diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx index a70cf97342..394b0126d5 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Header, Page, @@ -79,7 +79,7 @@ export const transformRunStatus = (x: Status[]) => { }); }; -const ProfileCatalog: FC<{}> = () => { +const ProfileCatalog = () => { // TODO: get data from REST API const [clusterTemplates] = React.useState([ { 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 19c9e95988..d6f4864a2f 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,64 @@ # @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 + +- 1dc445e89: Update to use new plugin extension API +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/core@0.4.2 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.1 ### 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 38c1fb1894..493378bf5f 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.1", + "version": "0.2.7", "private": false, "publishConfig": { "access": "public", @@ -31,23 +31,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", "graphiql": "^1.0.0-alpha.10", - "graphql": "15.3.0", + "graphql": "15.5.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/codemirror": "^0.0.100", "@types/jest": "^26.0.7", diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index ce56017984..35dcb1eb3b 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useState, Suspense } from 'react'; +import React, { useState, Suspense } from 'react'; import { Tabs, Tab, makeStyles, Typography, Divider } from '@material-ui/core'; import 'graphiql/graphiql.css'; import { StorageBucket } from '../../lib/storage'; @@ -47,7 +47,7 @@ type GraphiQLBrowserProps = { endpoints: GraphQLEndpoint[]; }; -export const GraphiQLBrowser: FC = ({ endpoints }) => { +export const GraphiQLBrowser = ({ endpoints }: GraphiQLBrowserProps) => { const classes = useStyles(); const [tabIndex, setTabIndex] = useState(0); diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts index 50698cdb4c..ed64779eba 100644 --- a/plugins/graphiql/src/index.ts +++ b/plugins/graphiql/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin } 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 28d27802d0..42969999d9 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -14,13 +14,19 @@ * limitations under the License. */ -import { createPlugin, createApiFactory } from '@backstage/core'; +import { + createPlugin, + createApiFactory, + createRoutableExtension, +} from '@backstage/core'; 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([ @@ -33,3 +39,10 @@ export const plugin = createPlugin({ ), ], }); + +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 477bd1bb8b..4a0ac8daca 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,29 @@ # @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 + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/plugin-catalog-graphql@0.2.4 + ## 0.1.3 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index f9121ebd9a..ab12ee5278 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "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/graphql-backend" + }, + "keywords": [ + "backstage", + "graphql" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -19,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/config": "^0.1.1", - "@backstage/plugin-catalog-graphql": "^0.2.3", + "@backstage/backend-common": "^0.5.2", + "@backstage/config": "^0.1.2", + "@backstage/plugin-catalog-graphql": "^0.2.6", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -35,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/graphql/src/service/router.test.ts b/plugins/graphql/src/service/router.test.ts index 983fbbb761..3bc52b0548 100644 --- a/plugins/graphql/src/service/router.test.ts +++ b/plugins/graphql/src/service/router.test.ts @@ -22,9 +22,7 @@ import express from 'express'; describe('Router', () => { describe('/health', () => { it('should return ok', async () => { - const config = ConfigReader.fromConfigs([ - { data: { backend: { baseUrl: 'lol' } }, context: 'something' }, - ]); + const config = new ConfigReader({ backend: { baseUrl: 'lol' } }); const router = await createRouter({ config, logger: createLogger() }); const app = express().use(router); diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index ca4e0284b5..8ba44c30f5 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,154 @@ # @backstage/plugin-jenkins +## 0.3.11 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.10 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.9 + +### Patch Changes + +- 53348f0af: Improve display of duration in latest build card +- 025c0c7bf: 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`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.3.3 + +### Patch Changes + +- b2a07d2dc: Avoid loading data from Jenkins twice. Don't load data when navigating through the pages as all data from all pages is already loaded. +- 04efbbdd2: Refactor to support ADR004 module exporting. + + For more information, see https://backstage.io/docs/architecture-decisions/adrs-adr004. + +- 0f8877168: Improve loading speed of the CI/CD page. + Only request the necessary fields from Jenkins to keep the request size low. + In addition everything is loaded in a single request, instead of requesting + each job and build individually. As this (and also the previous behavior) can + lead to a big amount of data, this limits the amount of jobs to 50. + For each job, only the latest build is loaded. Loading the full build history + of a job can lead to excessive load on the Jenkins instance. +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.3.2 ### Patch Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 5e0a701caf..13ff9b3547 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -50,7 +50,7 @@ metadata: name: 'your-component' description: 'a description' annotations: - jenkins.io/github-folder: 'folder-name/job-name' + jenkins.io/github-folder: 'folder-name/project-name' spec: type: service lifecycle: experimental @@ -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 projects backed by GitHub +- No pagination support currently, limited to 50 projects - 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 64bb7dfb20..04c653d17a 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.3.2", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/jenkins" + }, + "keywords": [ + "backstage", + "jenkins" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,14 +31,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", "jenkins": "^0.28.0", + "luxon": "^1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -36,11 +47,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 0fa48a1d17..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) => @@ -107,26 +111,48 @@ export class JenkinsApi { async getFolder(folderName: string) { const client = await this.getClient(); - const folder = await client.job.get(folderName); + const folder = await client.job.get({ + name: folderName, + // Filter only be the information we need, instead of loading all fields. + // Limit to only show the latest build for each job and only load 50 jobs + // at all. + // Whitespaces are only included for readablity here and stripped out + // before sending to Jenkins + tree: `jobs[ + actions[*], + builds[ + number, + url, + fullDisplayName, + building, + result, + actions[ + *[ + *[ + *[ + * + ] + ] + ] + ] + ]{0,1}, + jobs{0,1}, + name + ]{0,50} + `.replace(/\s/g, ''), + }); const results = []; - for (const jobSummary of folder.jobs) { - const jobDetails = await client.job.get({ - name: `${folderName}/${jobSummary.name}`, - depth: 1, - }); + 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) { - const build = await client.build.get({ - name: `${folderName}/${jobSummary.name}`, - number: buildDetails.number, - depth: 1, - }); - - const ciTable = this.mapJenkinsBuildToCITable(build, jobScmInfo); + const ciTable = this.mapJenkinsBuildToCITable( + buildDetails, + jobScmInfo, + ); results.push(ciTable); } } diff --git a/plugins/jenkins/src/assets/JenkinsLogo.svg b/plugins/jenkins/src/assets/JenkinsLogo.svg new file mode 100644 index 0000000000..4a4538daf6 --- /dev/null +++ b/plugins/jenkins/src/assets/JenkinsLogo.svg @@ -0,0 +1,283 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 637cd40d6d..5d9d81c985 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -15,10 +15,10 @@ */ import React from 'react'; import { useParams } from 'react-router-dom'; -import { Content, Link } from '@backstage/core'; +import { Breadcrumbs, Content, Link } from '@backstage/core'; import { + Box, Typography, - Breadcrumbs, Paper, TableContainer, Table, @@ -36,7 +36,6 @@ import ExternalLinkIcon from '@material-ui/icons/Launch'; const useStyles = makeStyles(theme => ({ root: { maxWidth: 720, - margin: theme.spacing(2), }, table: { padding: theme.spacing(1), @@ -47,12 +46,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const Page = () => ( - - - -); - const BuildWithStepsView = () => { const { owner, repo } = useProjectSlugFromEntity(); const { branch, buildNumber } = useParams(); @@ -63,9 +56,10 @@ const BuildWithStepsView = () => { return (
- Jobs + Projects Run +
@@ -129,6 +123,11 @@ const BuildWithStepsView = () => { ); }; +const Page = () => ( + + + +); export default Page; export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index 0dcaeb8948..9e9d432762 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -22,7 +22,7 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; const useStyles = makeStyles({ accordionDetails: { @@ -35,12 +35,14 @@ const useStyles = makeStyles({ }, }); -export const ActionOutput: FC<{ +type ActionOutputProps = { url: string; name: string; className?: string; action: any; -}> = ({ url, name, className }) => { +}; + +export const ActionOutput = ({ url, name, className }: ActionOutputProps) => { const classes = useStyles(); useEffect(() => {}, [url]); diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 5d5d7d9fcd..3044094ccc 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; -import GitHubIcon from '@material-ui/icons/GitHub'; +import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; import { JenkinsRunStatus } from '../Status'; @@ -201,7 +201,7 @@ type Props = { onChangePageSize: (pageSize: number) => void; }; -export const CITableView: FC = ({ +export const CITableView = ({ projectName, loading, pageSize, @@ -211,7 +211,7 @@ export const CITableView: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return (
= ({ onChangeRowsPerPage={onChangePageSize} title={ - - - {projectName} + Jenkins logo + + Project: {projectName} } columns={generatedColumns} diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 31acedc180..0d7c2fe919 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -13,12 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import { + InfoCard, + InfoCardVariants, + StructuredMetadataTable, +} from '@backstage/core'; +import { LinearProgress, Link, makeStyles, Theme } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; -import { useBuilds } from '../useBuilds'; +import { DateTime, Duration } from 'luxon'; +import React from 'react'; import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import { useBuilds } from '../useBuilds'; import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; const useStyles = makeStyles({ @@ -30,14 +35,21 @@ 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(); + const displayDuration = + (latestRun.building ? 'Running for ' : '') + + DateTime.local() + .minus(Duration.fromMillis(latestRun.duration)) + .toRelative({ locale: 'en' }) + ?.replace(' ago', ''); return ( ), - build: lastRun.fullDisplayName, - url: ( - + build: latestRun.fullDisplayName, + 'latest run': displayDate, + duration: displayDuration, + link: ( + See more on Jenkins{' '} @@ -66,14 +80,14 @@ export const LatestRunCard = ({ variant, }: { branch: string; - variant?: string; + variant?: InfoCardVariants; }) => { 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 62e3719dbd..a1ff3a1eef 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { errorApiRef, useApi } from '@backstage/core'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { jenkinsApiRef } from '../api'; @@ -26,21 +26,6 @@ export function useBuilds(owner: string, repo: string, branch?: string) { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(5); - const getBuilds = useCallback(async () => { - try { - let build; - if (branch) { - build = await api.getLastBuild(`${owner}/${repo}/${branch}`); - } else { - build = await api.getFolder(`${owner}/${repo}`); - } - return build; - } catch (e) { - errorApi.post(e); - return Promise.reject(e); - } - }, [api, branch, errorApi, owner, repo]); - const restartBuild = async (buildName: string) => { try { await api.retry(buildName); @@ -49,18 +34,24 @@ export function useBuilds(owner: string, repo: string, branch?: string) { } }; - useEffect(() => { - getBuilds().then(b => { - const size = Array.isArray(b) ? b?.[0].build_num! : 1; - setTotal(size); - }); - }, [repo, getBuilds]); + const { loading, value: builds, retry } = useAsyncRetry(async () => { + try { + let build; + if (branch) { + build = await api.getLastBuild(`${owner}/${repo}/${branch}`); + } else { + build = await api.getFolder(`${owner}/${repo}`); + } - const { loading, value: builds, retry } = useAsyncRetry( - () => - getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild), - [page, pageSize, getBuilds], - ); + const size = Array.isArray(build) ? build?.[0].build_num! : 1; + setTotal(size); + + return build || []; + } catch (e) { + errorApi.post(e); + throw e; + } + }, [api, errorApi, owner, repo, branch]); const projectName = `${owner}/${repo}`; return [ 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..1280e30633 --- /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", + "config.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/catalog-import/src/util/validate.test.ts b/plugins/kafka-backend/src/config/ClusterReader.ts similarity index 50% rename from plugins/catalog-import/src/util/validate.test.ts rename to plugins/kafka-backend/src/config/ClusterReader.ts index 139c7fb0f8..a4e6a4e4fe 100644 --- a/plugins/catalog-import/src/util/validate.test.ts +++ b/plugins/kafka-backend/src/config/ClusterReader.ts @@ -14,20 +14,24 @@ * limitations under the License. */ -import { ComponentIdValidators } from './validate'; +import { Config } from '@backstage/config'; +import { ConnectionOptions } from 'tls'; +import { ClusterDetails } from '../types/types'; -describe('ComponentIdValidators', () => { - describe('httpsValidator validator', () => { - const errorMessage = 'Must start with https://.'; - test.each([ - [true, 'https://example.com'], - [errorMessage, 'http://example.com'], - [errorMessage, 'example.com'], - [errorMessage, 'www.example.com'], - [errorMessage, ''], - [errorMessage, undefined], - ])('should return %p for %s', (expected: string | boolean, arg: any) => { - expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected); - }); +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/api-docs/src/catalog/EntityPageApi/index.ts b/plugins/kafka-backend/src/index.ts similarity index 92% rename from plugins/api-docs/src/catalog/EntityPageApi/index.ts rename to plugins/kafka-backend/src/index.ts index 1d382e01de..bf496e05bf 100644 --- a/plugins/api-docs/src/catalog/EntityPageApi/index.ts +++ b/plugins/kafka-backend/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { EntityPageApi } from './EntityPageApi'; +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..3360bbb1de --- /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.json({ 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/kafka-backend/src/setupTests.ts b/plugins/kafka-backend/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/plugins/kafka-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/kafka-backend/src/types/types.ts b/plugins/kafka-backend/src/types/types.ts new file mode 100644 index 0000000000..5f70998dd8 --- /dev/null +++ b/plugins/kafka-backend/src/types/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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..a8153002c9 --- /dev/null +++ b/plugins/kafka/CHANGELOG.md @@ -0,0 +1,121 @@ +# @backstage/plugin-kafka + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.2 + +### Patch Changes + +- 7716d1d70: Migrate to new composability API, exporting the plugin instance as `kafkaPlugin`, entity content as `EntityKafkaContent`, and entity conditional as `isKafkaAvailable`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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..3e72ac5fca --- /dev/null +++ b/plugins/kafka/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-kafka", + "version": "0.2.4", + "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.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@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 dc875669ef..e02c3e14a0 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,76 @@ # @backstage/plugin-kubernetes-backend +## 0.2.7 + +### Patch Changes + +- a70af22a2: update kubernetes plugin backend function to use classes +- Updated dependencies [bad21a085] +- Updated dependencies [a1f5e6545] + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.2.1 ### 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 9d1ff2eed4..9d5a0c25e0 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.1", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/kubernetes-backend" + }, + "keywords": [ + "backstage", + "kubernetes" + ], "configSchema": "schema.d.ts", "scripts": { "start": "backstage-cli backend:dev", @@ -21,11 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@kubernetes/client-node": "^0.12.1", + "@aws-sdk/credential-provider-node": "^3.3.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@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", @@ -39,7 +51,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.2", + "@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/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index 550703e430..339f886a53 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -20,12 +20,9 @@ import { ConfigClusterLocator } from './ConfigClusterLocator'; describe('ConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const config: Config = new ConfigReader( - { - clusters: [], - }, - 'ctx', - ); + const config: Config = new ConfigReader({ + clusters: [], + }); const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), @@ -37,18 +34,15 @@ describe('ConfigClusterLocator', () => { }); it('one clusters returns one cluster details', async () => { - const config: Config = new ConfigReader( - { - clusters: [ - { - name: 'cluster1', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - }, - ], - }, - 'ctx', - ); + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + ], + }); const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), @@ -67,24 +61,21 @@ describe('ConfigClusterLocator', () => { }); it('two clusters returns two cluster details', async () => { - const config: Config = new ConfigReader( - { - clusters: [ - { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', - }, - ], - }, - 'ctx', - ); + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], + }); const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 5677fa2d0d..63018cd338 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -60,7 +60,7 @@ describe('getCombinedClusterDetails', () => { it('throws an error when using an unsupported cluster locator', async () => { await expect( - getCombinedClusterDetails(['magic' as any], new ConfigReader({}, 'ctx')), + getCombinedClusterDetails(['magic' as any], new ConfigReader({})), ).rejects.toStrictEqual( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); 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/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts similarity index 73% rename from plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts rename to plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 5c6513e57c..95ec4f0aba 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { handleGetKubernetesObjectsForService } from './getKubernetesObjectsForServiceHandler'; import { getVoidLogger } from '@backstage/backend-common'; import { ObjectFetchParams } from '..'; - -const TEST_SERVICE_ID = 'my-service'; +import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; const fetchObjectsForService = jest.fn(); @@ -81,34 +79,34 @@ describe('handleGetKubernetesObjectsForService', () => { mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsForService( - TEST_SERVICE_ID, + const sut = new KubernetesFanOutHandler( + getVoidLogger(), { - fetchObjectsForService: fetchObjectsForService, + fetchObjectsForService, }, { getClustersByServiceId, }, - getVoidLogger(), - { - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', + ); + + const result = await sut.getKubernetesObjectsByEntity({ + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', }, }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, }, - ); + }); expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsForService.mock.calls.length).toBe(1); @@ -124,7 +122,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-pods-my-service-test-cluster', + name: 'my-pods-test-component-test-cluster', }, }, ], @@ -134,7 +132,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-configmaps-my-service-test-cluster', + name: 'my-configmaps-test-component-test-cluster', }, }, ], @@ -144,7 +142,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-services-my-service-test-cluster', + name: 'my-services-test-component-test-cluster', }, }, ], @@ -172,37 +170,37 @@ describe('handleGetKubernetesObjectsForService', () => { mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsForService( - TEST_SERVICE_ID, + const sut = new KubernetesFanOutHandler( + getVoidLogger(), { - fetchObjectsForService: fetchObjectsForService, + fetchObjectsForService, }, { getClustersByServiceId, }, - getVoidLogger(), - { - auth: { - google: 'google_token_123', + ); + + const result = await sut.getKubernetesObjectsByEntity({ + auth: { + google: 'google_token_123', + }, + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, }, - entity: { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { - name: 'test-component', - annotations: { - 'backstage.io/kubernetes-labels-selector': - 'backstage.io/test-label=test-component', - }, - }, - spec: { - type: 'service', - lifecycle: 'production', - owner: 'joe', - }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', }, }, - ); + }); expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsForService.mock.calls.length).toBe(2); @@ -218,7 +216,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-pods-my-service-test-cluster', + name: 'my-pods-test-component-test-cluster', }, }, ], @@ -228,7 +226,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-configmaps-my-service-test-cluster', + name: 'my-configmaps-test-component-test-cluster', }, }, ], @@ -238,7 +236,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-services-my-service-test-cluster', + name: 'my-services-test-component-test-cluster', }, }, ], @@ -256,7 +254,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-pods-my-service-other-cluster', + name: 'my-pods-test-component-other-cluster', }, }, ], @@ -266,7 +264,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-configmaps-my-service-other-cluster', + name: 'my-configmaps-test-component-other-cluster', }, }, ], @@ -276,7 +274,7 @@ describe('handleGetKubernetesObjectsForService', () => { resources: [ { metadata: { - name: 'my-services-my-service-other-cluster', + name: 'my-services-test-component-other-cluster', }, }, ], diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts new file mode 100644 index 0000000000..0a2fc1488a --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -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 { Logger } from 'winston'; +import { + ClusterDetails, + KubernetesFetcher, + KubernetesObjectTypes, + KubernetesRequestBody, + KubernetesServiceLocator, +} from '../types/types'; +import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; +import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; + +const DEFAULT_OBJECTS = new Set([ + 'pods', + 'services', + 'configmaps', + 'deployments', + 'replicasets', + 'horizontalpodautoscalers', + 'ingresses', +]); + +export class KubernetesFanOutHandler { + private readonly logger: Logger; + private readonly fetcher: KubernetesFetcher; + private readonly serviceLocator: KubernetesServiceLocator; + + constructor( + logger: Logger, + fetcher: KubernetesFetcher, + serviceLocator: KubernetesServiceLocator, + ) { + this.logger = logger; + this.fetcher = fetcher; + this.serviceLocator = serviceLocator; + } + + async getKubernetesObjectsByEntity( + requestBody: KubernetesRequestBody, + objectTypesToFetch: Set = DEFAULT_OBJECTS, + ) { + const entityName = requestBody.entity.metadata.name; + + const clusterDetails: ClusterDetails[] = await this.serviceLocator.getClustersByServiceId( + entityName, + ); + + // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them + const promises: Promise[] = clusterDetails.map(cd => { + const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + cd.authProvider, + ); + return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( + cd, + requestBody, + ); + }); + const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( + promises, + ); + + this.logger.info( + `entity.metadata.name=${entityName} clusterDetails=[${clusterDetailsDecoratedForAuth + .map(c => c.name) + .join(', ')}]`, + ); + + const labelSelector: string = + requestBody.entity?.metadata?.annotations?.[ + 'backstage.io/kubernetes-label-selector' + ] || `backstage.io/kubernetes-id=${entityName}`; + + return Promise.all( + clusterDetailsDecoratedForAuth.map(clusterDetailsItem => { + return this.fetcher + .fetchObjectsForService({ + serviceId: entityName, + clusterDetails: clusterDetailsItem, + objectTypesToFetch, + labelSelector, + }) + .then(result => { + return { + cluster: { + name: clusterDetailsItem.name, + }, + resources: result.responses, + errors: result.errors, + }; + }); + }), + ).then(r => ({ items: r })); + } +} 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 deleted file mode 100644 index 2dd1e32b96..0000000000 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts +++ /dev/null @@ -1,107 +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 { Logger } from 'winston'; -import { - KubernetesRequestBody, - ClusterDetails, - KubernetesServiceLocator, - KubernetesFetcher, - KubernetesObjectTypes, - ObjectsByEntityResponse, - ObjectFetchParams, -} from '../types/types'; -import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; -import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; - -export type GetKubernetesObjectsForServiceHandler = ( - serviceId: string, - fetcher: KubernetesFetcher, - serviceLocator: KubernetesServiceLocator, - logger: Logger, - requestBody: KubernetesRequestBody, - objectTypesToFetch?: Set, -) => Promise; - -const DEFAULT_OBJECTS = new Set([ - 'pods', - 'services', - 'configmaps', - 'deployments', - 'replicasets', - 'horizontalpodautoscalers', - 'ingresses', -]); - -// Fans out the request to all clusters that the service lives in, aggregates their responses together -export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServiceHandler = async ( - serviceId, - fetcher, - serviceLocator, - logger, - requestBody, - objectTypesToFetch = DEFAULT_OBJECTS, -) => { - const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId( - serviceId, - ); - - // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them - const promises: Promise[] = clusterDetails.map(cd => { - const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( - cd.authProvider, - ); - return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( - cd, - requestBody, - ); - }); - const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( - promises, - ); - - logger.info( - `serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth - .map(c => c.name) - .join(', ')}]`, - ); - - const labelSelector: string = - requestBody.entity?.metadata?.annotations?.[ - 'backstage.io/kubernetes-label-selector' - ] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`; - - return Promise.all( - clusterDetailsDecoratedForAuth.map(clusterDetails => { - return fetcher - .fetchObjectsForService({ - serviceId, - clusterDetails, - objectTypesToFetch, - labelSelector, - } as ObjectFetchParams) - .then(result => { - return { - cluster: { - name: clusterDetails.name, - }, - resources: result.responses, - errors: result.errors, - }; - }); - }), - ).then(r => ({ items: r })); -}; diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index a407ce9693..c91cae27df 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -18,35 +18,18 @@ import { getVoidLogger } from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; import { makeRouter } from './router'; -import { - KubernetesServiceLocator, - KubernetesFetcher, - ObjectsByEntityResponse, -} from '..'; +import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; describe('router', () => { let app: express.Express; - let kubernetesFetcher: jest.Mocked; - let kubernetesServiceLocator: jest.Mocked; - let handleGetByServiceId: jest.Mock>; + let kubernetesFanOutHandler: jest.Mocked; beforeAll(async () => { - kubernetesFetcher = { - fetchObjectsForService: jest.fn(), - }; + kubernetesFanOutHandler = { + getKubernetesObjectsByEntity: jest.fn(), + } as any; - kubernetesServiceLocator = { - getClustersByServiceId: jest.fn(), - }; - - handleGetByServiceId = jest.fn(); - - const router = makeRouter( - getVoidLogger(), - kubernetesFetcher, - kubernetesServiceLocator, - handleGetByServiceId as any, - ); + const router = makeRouter(getVoidLogger(), kubernetesFanOutHandler); app = express().use(router); }); @@ -67,7 +50,9 @@ describe('router', () => { ], }, } as any; - handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockReturnValueOnce( + Promise.resolve(result), + ); const response = await request(app).post('/services/test-service'); @@ -87,7 +72,9 @@ describe('router', () => { ], }, } as any; - handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockReturnValueOnce( + Promise.resolve(result), + ); const response = await request(app) .post('/services/test-service') @@ -103,7 +90,9 @@ describe('router', () => { }); it('internal error: lists kubernetes objects', async () => { - handleGetByServiceId.mockRejectedValue(Error('some internal error')); + kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockRejectedValue( + Error('some internal error'), + ); const response = await request(app).post('/services/test-service'); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 38a02ad970..333f48a623 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -21,19 +21,15 @@ import { Config } from '@backstage/config'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesClientProvider } from './KubernetesClientProvider'; -import { - GetKubernetesObjectsForServiceHandler, - handleGetKubernetesObjectsForService, -} from './getKubernetesObjectsForServiceHandler'; import { KubernetesRequestBody, KubernetesServiceLocator, - KubernetesFetcher, ServiceLocatorMethod, ClusterLocatorMethod, ClusterDetails, } from '..'; import { getCombinedClusterDetails } from '../cluster-locator'; +import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; export interface RouterOptions { logger: Logger; @@ -62,9 +58,7 @@ const getServiceLocator = ( export const makeRouter = ( logger: Logger, - fetcher: KubernetesFetcher, - serviceLocator: KubernetesServiceLocator, - handleGetByEntity: GetKubernetesObjectsForServiceHandler, + kubernetesFanOutHandler: KubernetesFanOutHandler, ): express.Router => { const router = Router(); router.use(express.json()); @@ -73,19 +67,15 @@ export const makeRouter = ( const serviceId = req.params.serviceId; const requestBody: KubernetesRequestBody = req.body; try { - const response = await handleGetByEntity( - serviceId, - fetcher, - serviceLocator, - logger, + const response = await kubernetesFanOutHandler.getKubernetesObjectsByEntity( requestBody, ); - res.send(response); + res.json(response); } catch (e) { logger.error( `action=retrieveObjectsByServiceId service=${serviceId}, error=${e}`, ); - res.status(500).send({ error: e.message }); + res.status(500).json({ error: e.message }); } }); @@ -115,10 +105,11 @@ export async function createRouter( const serviceLocator = getServiceLocator(options.config, clusterDetails); - return makeRouter( + const kubernetesFanOutHandler = new KubernetesFanOutHandler( logger, fetcher, serviceLocator, - handleGetKubernetesObjectsForService, ); + + return makeRouter(logger, kubernetesFanOutHandler); } diff --git a/plugins/kubernetes-backend/src/service/standaloneApplication.ts b/plugins/kubernetes-backend/src/service/standaloneApplication.ts index e23e87a7a2..50563023d0 100644 --- a/plugins/kubernetes-backend/src/service/standaloneApplication.ts +++ b/plugins/kubernetes-backend/src/service/standaloneApplication.ts @@ -36,7 +36,7 @@ export async function createStandaloneApplication( options: ApplicationOptions, ): Promise { const { enableCors, logger } = options; - const config = ConfigReader.fromConfigs([]); + const config = new ConfigReader({}); const app = express(); app.use(helmet()); 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 9bc3004913..75df5943dd 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,150 @@ # @backstage/plugin-kubernetes +## 0.3.11 + +### Patch Changes + +- Updated dependencies [a70af22a2] +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [a1f5e6545] +- Updated dependencies [2c1f2a7c2] + - @backstage/plugin-kubernetes-backend@0.2.7 + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.3.10 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.9 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 64b9efac2: Migrate to new composability API, exporting the plugin instance as `kubernetesPlugin` and entity content as `EntityKubernetesContent`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-kubernetes-backend@0.2.3 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [e3bd9fc2f] + - @backstage/core@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-kubernetes-backend@0.2.2 + ## 0.3.1 ### 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 6fa2f7e3ff..24bfc1c11f 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.3.1", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "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/kubernetes" + }, + "keywords": [ + "backstage", + "kubernetes" + ], "configSchema": "schema.d.ts", "scripts": { "build": "backstage-cli plugin:build", @@ -21,26 +31,28 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/plugin-kubernetes-backend": "^0.2.1", - "@backstage/theme": "^0.2.1", - "@kubernetes/client-node": "^0.12.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/config": "^0.1.3", + "@backstage/core": "^0.6.3", + "@backstage/plugin-kubernetes-backend": "^0.2.7", + "@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.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", 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..838bfef60a 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -16,28 +16,59 @@ 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'; +import { Button } from '@material-ui/core'; const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; +const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = + 'backstage.io/kubernetes-label-selector'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const Router = (_props: Props) => { + const { entity } = useEntity(); -export const Router = ({ entity }: { entity: Entity }) => { const kubernetesAnnotationValue = entity.metadata.annotations?.[KUBERNETES_ANNOTATION]; - if (!kubernetesAnnotationValue) { - return ; + const kubernetesLabelSelectorQueryAnnotationValue = + entity.metadata.annotations?.[KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION]; + + if ( + kubernetesAnnotationValue || + kubernetesLabelSelectorQueryAnnotationValue + ) { + return ( + + } + /> + + ); } return ( - - } - /> - + <> + +

+ Or use a label selector query, which takes precedence over the previous + annotation. +

+ + ); }; diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index b81a799d6b..dcb2cca142 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { KubernetesApi } from './types'; import { KubernetesRequestBody, @@ -23,9 +23,14 @@ import { export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; } private async getRequired( @@ -33,17 +38,27 @@ export class KubernetesBackendClient implements KubernetesApi { requestBody: KubernetesRequestBody, ): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; + const idToken = await this.identityApi.getIdToken(); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), }, body: JSON.stringify(requestBody), }); 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/DeploymentTables/index.ts b/plugins/kubernetes/src/components/DeploymentTables/index.ts deleted file mode 100644 index dac38c5c81..0000000000 --- a/plugins/kubernetes/src/components/DeploymentTables/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { DeploymentTables } from './DeploymentTables'; 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/kubernetes/src/components/DeploymentsAccordions/index.ts b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts new file mode 100644 index 0000000000..923fec624b --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/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 { 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/ServicesAccordions/index.ts b/plugins/kubernetes/src/components/ServicesAccordions/index.ts new file mode 100644 index 0000000000..a3c392c743 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/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 { 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/sentry/src/data/api-factory.ts b/plugins/kubernetes/src/kubernetes-auth-provider/AwsKubernetesAuthProvider.ts similarity index 59% rename from plugins/sentry/src/data/api-factory.ts rename to plugins/kubernetes/src/kubernetes-auth-provider/AwsKubernetesAuthProvider.ts index 88e1148b72..cab81f3622 100644 --- a/plugins/sentry/src/data/api-factory.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/AwsKubernetesAuthProvider.ts @@ -13,16 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryApi } from './sentry-api'; -import { MockSentryApi } from './mock-api'; -import { ProductionSentryApi } from './production-api'; -export function sentryApiFactory( - organization: string, - backendBaseUrl: string, -): SentryApi { - if (process.env.NODE_ENV === 'production') { - return new ProductionSentryApi(organization, backendBaseUrl); +import { KubernetesAuthProvider } from './types'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class AwsKubernetesAuthProvider implements KubernetesAuthProvider { + async decorateRequestBodyForAuth( + requestBody: KubernetesRequestBody, + ): Promise { + // No-op, with aws auth, server's AWS credentials are used for access + return requestBody; } - return new MockSentryApi(); } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 9f64af9c2e..dc9d9b5e0d 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -19,6 +19,7 @@ import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; +import { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider'; export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { private readonly kubernetesAuthProviderMap: Map< @@ -36,6 +37,7 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { 'serviceAccount', new ServiceAccountKubernetesAuthProvider(), ); + this.kubernetesAuthProviderMap.set('aws', new AwsKubernetesAuthProvider()); } async decorateRequestBodyForAuth( 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..24f51fe64a 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -18,7 +18,9 @@ import { createPlugin, createRouteRef, discoveryApiRef, + identityApiRef, googleAuthApiRef, + createRoutableExtension, } from '@backstage/core'; import { KubernetesBackendClient } from './api/KubernetesBackendClient'; import { kubernetesApiRef } from './api/types'; @@ -30,14 +32,14 @@ export const rootCatalogKubernetesRouteRef = createRouteRef({ title: 'Kubernetes', }); -export const plugin = createPlugin({ +export const kubernetesPlugin = createPlugin({ id: 'kubernetes', apis: [ createApiFactory({ api: kubernetesApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => - new KubernetesBackendClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new KubernetesBackendClient({ discoveryApi, identityApi }), }), createApiFactory({ api: kubernetesAuthProvidersApiRef, @@ -47,4 +49,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 2106111b5e..20b1a5c1e1 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,144 @@ # @backstage/plugin-lighthouse +## 0.2.12 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [a1f5e6545] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.2.11 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.10 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [7dd2ef7d1] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/core-api@0.2.6 + - @backstage/plugin-catalog@0.2.7 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [e3bd9fc2f] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.4 ### Patch Changes diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index cdcc2fe2c4..149172b7e8 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -2,9 +2,7 @@ A frontend for [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service), this plugin allows you to trigger Lighthouse audits on websites and track them over time. -## Getting Started - -### Use cases +## Introduction Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site. @@ -13,55 +11,59 @@ At Spotify, we keep track of Lighthouse audit scores over time to look at trends This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the top-level categories of Lighthouse at a glance. +You can learn more in our blog post [Introducing Lighthouse for Backstage](https://backstage.io/blog/2020/04/06/lighthouse-plugin). + +| List of audits | Specific audit | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| ![Screen shot of the main Lighthouse plugin page](images/lighthouse-page.png) | ![Screen shot of the resulting audit in the Lighthouse plugin](images/audit-view.png) | + In the future, we hope to add support for scheduling audits (which we do internally), as well as allowing custom runs of Lighthouse to be ingested (for auditing sites that require authentication or some session state). -### Installation +## Getting Started -To get started, you will need a running instance of [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). -_It's likely you will need to enable CORS when running lighthouse-audit-service. Initialize the app -with the environment variable `LAS_CORS` set to `true`._ +To get started, you will need a running instance of [`lighthouse-audit-service`](https://github.com/spotify/lighthouse-audit-service). +_It's likely you will need to [enable CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) to integrate with Backstage, so initialize the `lighthouse-audit-service` with the environment variable `LAS_CORS` set to `true`._ -When you have an instance running that Backstage can hook into, make sure to export the plugin in +When you have an instance running that Backstage can hook into, first install the plugin into your app: + +```sh +$ yarn add @backstage/plugin-lighthouse +``` + +Then make sure to export the plugin in your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: ```js -import { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export LighthousePlugin; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; ``` -Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in -your [`apis.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts). +Modify your app routes in `App.tsx` to include the Router component exported from the plugin, for example: -```js -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { Config } from '@backstage/config'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; +```tsx +// At the top imports +import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; -export const apis = (config: ConfigApi) => { - const builder = ApiRegistry.builder(); - - builder.add(lighthouseApiRef, LighthouseRestApi.fromConfig(config)); - - return builder.build() as ApiHolder; -} +// Inside App component + + // ... + } /> + // ... +; ``` -Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). +Then configure the `lighthouse-audit-service` URL in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). ```yaml lighthouse: baseUrl: http://your-service-url ``` -### Integration with the Catalog +## Integration with the Catalog -The lighthouse plugin can be integrated into the catalog so that lighthouse audit information relating to a component -can be displayed within that component's entity page. In order to link an Entity to its lighthouse audits the entity +The Lighthouse plugin can be integrated into the catalog so that Lighthouse audit information relating to a component +can be displayed within that component's entity page. In order to link an Entity to its Lighthouse audits, the entity must be annotated as follows: ```yaml @@ -74,9 +76,9 @@ metadata: lighthouse.com/website-url: # A single website url e.g. https://backstage.io/ ``` -> NOTE: The lighthouse plugin only supports one website url per component at this time. +> NOTE: The plugin only supports one website URL per component at this time. -Add a lighthouse tab to the EntityPage: +Add a **Lighthouse tab** to the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx @@ -87,7 +89,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( // ... } /> @@ -98,7 +100,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( > NOTE: The embedded router renders page content without a header section allowing it to be rendered within a > catalog plugin page. -Add a Lighthouse card to the overview tab on the EntityPage: +Add a **Lighthouse card** to the overview tab on the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx @@ -120,3 +122,5 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); ``` + +Link Lighthouse 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/images/audit-view.png b/plugins/lighthouse/images/audit-view.png new file mode 100644 index 0000000000..1c2fb863b5 Binary files /dev/null and b/plugins/lighthouse/images/audit-view.png differ diff --git a/plugins/lighthouse/images/lighthouse-page.png b/plugins/lighthouse/images/lighthouse-page.png new file mode 100644 index 0000000000..5b249edda1 Binary files /dev/null and b/plugins/lighthouse/images/lighthouse-page.png differ diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index da824044a9..e3e1570b53 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.4", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/lighthouse" + }, + "keywords": [ + "backstage", + "lighthouse" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,31 +31,30 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/core-api": "^0.2.4", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", "@testing-library/react-hooks": "^3.4.2", - "@types/react": "^16.9", "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.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, 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 332dd5e3ca..8a59869266 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Table, TableColumn, TrendLine, useApi } from '@backstage/core'; import { Website, lighthouseApiRef } from '../../api'; import { useInterval } from 'react-use'; @@ -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[] = [ { @@ -52,7 +51,7 @@ const columns: TableColumn[] = [ }, ]; -export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { +export const AuditListTable = ({ items }: { items: Website[] }) => { const [websiteState, setWebsiteState] = useState(items); const lighthouseApi = useApi(lighthouseApiRef); @@ -99,11 +98,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { 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 c6411a9baa..8d7f769909 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useMemo, FC, ReactNode } from 'react'; +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,11 +35,10 @@ 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; -const AuditList: FC<{}> = () => { +const AuditList = () => { const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); const [dismissed, setDismissed] = useState(dismissedStored); @@ -86,9 +85,9 @@ const AuditList: FC<{}> = () => { content = ; } else if (error) { content = ( - + {error.message} - + ); } @@ -110,7 +109,7 @@ const AuditList: FC<{}> = () => { diff --git a/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx index bce917d428..df6dcd148d 100644 --- a/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx +++ b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { StatusPending, StatusError, StatusOK } from '@backstage/core'; import { Audit } from '../../api'; -const AuditStatusIcon: FC<{ audit: Audit }> = ({ audit }) => { +const AuditStatusIcon = ({ audit }: { audit: Audit }) => { if (audit.status === 'FAILED') return ; if (audit.status === 'COMPLETED') return ; return ; diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index ac38a329f1..2be284183c 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect, ReactNode, FC } from 'react'; +import React, { useState, useEffect, ReactNode } from 'react'; import { Link, useParams, @@ -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: { @@ -65,10 +64,7 @@ interface AuditLinkListProps { audits?: Audit[]; selectedId: string; } -const AuditLinkList: FC = ({ - audits = [], - selectedId, -}: AuditLinkListProps) => ( +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 }), '../../')} > @@ -97,7 +88,7 @@ const AuditLinkList: FC = ({ ); -const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { +const AuditView = ({ audit }: { audit?: Audit }) => { const classes = useStyles(); const params = useParams() as { id: string }; const { url: lighthouseUrl } = useApi(lighthouseApiRef); @@ -123,7 +114,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { ); }; -export const AuditViewContent: FC<{}> = () => { +export const AuditViewContent = () => { const lighthouseApi = useApi(lighthouseApiRef); const params = useParams() as { id: string }; const classes = useStyles(); @@ -166,7 +157,7 @@ export const AuditViewContent: FC<{}> = () => { ); } - 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 3f7020cba5..693a8b6ec6 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; import { InfoCard, + InfoCardVariants, Progress, StatusError, StatusOK, StatusWarning, StructuredMetadataTable, } from '@backstage/core'; +import React from 'react'; +import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import AuditStatusIcon from '../AuditStatusIcon'; -const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => { - const scoreAsPercentage = score * 100; +const LighthouseCategoryScoreStatus = ({ score }: { score: number }) => { + const scoreAsPercentage = Math.round(score * 100); switch (true) { case scoreAsPercentage >= 90: return ( @@ -55,16 +56,19 @@ const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => { } }; -const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => ( +const LighthouseAuditStatus = ({ audit }: { audit: Audit }) => ( <> {audit.status.toUpperCase()} ); -const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ +const LighthouseAuditSummary = ({ audit, dense = false, +}: { + audit: Audit; + dense?: boolean; }) => { const { url } = audit; const flattenedCategoryData: Record = {}; @@ -88,10 +92,13 @@ const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ return ; }; -export const LastLighthouseAuditCard: FC<{ +export const LastLighthouseAuditCard = ({ + dense = false, + variant, +}: { dense?: boolean; - variant?: string; -}> = ({ dense = false, variant }) => { + variant?: InfoCardVariants; +}) => { const { value: website, loading, error } = useWebsiteForEntity(); let content; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 55800d79ec..8f514eb54a 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useCallback, FC } from 'react'; +import React, { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { makeStyles, @@ -63,7 +63,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CreateAuditContent: FC<{}> = () => { +export const CreateAuditContent = () => { const errorApi = useApi(errorApiRef); const lighthouseApi = useApi(lighthouseApiRef); const classes = useStyles(); @@ -79,7 +79,7 @@ export const CreateAuditContent: FC<{}> = () => { // 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/components/Intro/index.tsx b/plugins/lighthouse/src/components/Intro/index.tsx index 3e969a3c74..b140141c9f 100644 --- a/plugins/lighthouse/src/components/Intro/index.tsx +++ b/plugins/lighthouse/src/components/Intro/index.tsx @@ -26,7 +26,7 @@ export const LIGHTHOUSE_INTRO_LOCAL_STORAGE = const USE_CASES = ` Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites -is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site. +is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site. At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment. This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the @@ -46,8 +46,7 @@ your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/p to enable the plugin: \`\`\`js -import { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; -export LighthousePlugin; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; \`\`\` Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 580162f182..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 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]; @@ -51,7 +51,7 @@ describe('useWebsiteForEntity', () => { }, }; - const wrapper: React.FC<{}> = ({ children }) => { + const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( { 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 86b71d497e..ab8ad1c289 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,48 @@ # @backstage/plugin-newrelic +## 0.2.5 + +### Patch Changes + +- c5ab91ce3: Migrate to new composability API, exporting the plugin instance as `newRelicPlugin`, and the root page as `NewRelicPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.1 ### 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 9cfb810f4a..e8a6786cd8 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.2.1", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/newrelic" + }, + "keywords": [ + "backstage", + "newrelic" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", @@ -31,11 +41,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx index 81e28a8c80..27e021e518 100644 --- a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import { Header, @@ -26,7 +26,7 @@ import { } from '@backstage/core'; import NewRelicFetchComponent from '../NewRelicFetchComponent'; -const NewRelicComponent: FC<{}> = () => ( +const NewRelicComponent = () => (
diff --git a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx index 62f52e7875..153a80501f 100644 --- a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Progress, Table, TableColumn, useApi } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; import { useAsync } from 'react-use'; import { newRelicApiRef, NewRelicApplications } from '../../api'; -export const NewRelicAPMTable: FC = ({ - applications, -}) => { +export const NewRelicAPMTable = ({ applications }: NewRelicApplications) => { const columns: TableColumn[] = [ { title: 'Application', field: 'name' }, { title: 'Response Time', field: 'responseTime' }, @@ -61,7 +59,7 @@ export const NewRelicAPMTable: FC = ({ ); }; -const NewRelicFetchComponent: FC<{}> = () => { +const NewRelicFetchComponent = () => { const api = useApi(newRelicApiRef); const { value, loading, error } = useAsync(async () => { 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/.eslintrc.js b/plugins/org/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/org/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md new file mode 100644 index 0000000000..474d44a735 --- /dev/null +++ b/plugins/org/CHANGELOG.md @@ -0,0 +1,154 @@ +# @backstage/plugin-org + +## 0.3.8 + +### Patch Changes + +- e3bc5aad7: Use the `pageTheme` to colour the OwnershipCard boxes with their respective theme colours. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [b6c4f485d] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core-api@0.2.11 + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.7 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- e8692df4a: - Fixes padding in `MembersListCard` + - Fixes email icon size in `GroupProfileCard` + - Uniform sizing across `GroupProfileCard` and `UserProfileCard` +- Updated dependencies [f10950bd2] +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core-api@0.2.10 + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.6 + +### Patch Changes + +- 14aef4b94: Visual updates to User and Group pages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- c0fac6163: Wrap entity cards on smaller screens +- ab805860a: Ensure a name is always displayed for user entities in the org plugin. This can happen when there is no profile + displayName provided (e.g. a GitHub user that has not added a name to their profile) +- 8ef71ed32: Add a `` component to `@backstage/core`. +- c5297baeb: Display the new `profile` fields (`displayName`, `email`, and `picture`) for + groups on the `GroupProfileCard`. + + This also resolves some cases where `profile` fields are missing for users or + groups and for example falls back to displaying the entity name. Adds additional test data to the ACME Corp dataset. + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.3.1 + +### Patch Changes + +- 2b71db211: Support transitive ownerships of users and groups. +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 diff --git a/plugins/org/README.md b/plugins/org/README.md new file mode 100644 index 0000000000..a117f36722 --- /dev/null +++ b/plugins/org/README.md @@ -0,0 +1,6 @@ +# Org Plugin for Backstage + +## Features + +- Show Group Page +- Show User Profile diff --git a/plugins/org/dev/index.tsx b/plugins/org/dev/index.tsx new file mode 100644 index 0000000000..01951eaf03 --- /dev/null +++ b/plugins/org/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 { orgPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(orgPlugin).render(); diff --git a/plugins/org/package.json b/plugins/org/package.json new file mode 100644 index 0000000000..c7cf35dcd7 --- /dev/null +++ b/plugins/org/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-org", + "version": "0.3.8", + "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.2", + "@backstage/core": "^0.6.3", + "@backstage/core-api": "^0.2.11", + "@backstage/plugin-catalog-react": "^0.1.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": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@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/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx new file mode 100644 index 0000000000..39d30d8165 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.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 { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { GroupEntity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { GroupProfileCard } from '.'; + +export default { + title: 'Plugins/Org/Group Profile Card', + component: GroupProfileCard, +}; + +const dummyDepartment = { + type: 'childOf', + target: { + namespace: 'default', + kind: 'group', + name: 'department-a', + }, +}; + +const defaultEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, + relations: [dummyDepartment], +}; + +export const Default = () => ( + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx new file mode 100644 index 0000000000..ce13f8391f --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + GroupEntity, + RELATION_CHILD_OF, + RELATION_PARENT_OF, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core'; +import { + entityRouteParams, + getEntityRelations, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { + Box, + Grid, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, +} from '@material-ui/core'; +import AccountTreeIcon from '@material-ui/icons/AccountTree'; +import EmailIcon from '@material-ui/icons/Email'; +import GroupIcon from '@material-ui/icons/Group'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const GroupLink = ({ + groupName, + index = 0, +}: { + groupName: string; + index?: number; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}) => { + const { entity } = useEntity(); + + return ( + <> + {index >= 1 ? ', ' : ''} + + [{groupName}] + + + ); +}; +const CardTitle = ({ title }: { title: string }) => ( + + + {title} + +); + +export const GroupProfileCard = ({ + variant, +}: { + /** @deprecated The entity is now grabbed from context instead */ + entity?: GroupEntity; + variant?: InfoCardVariants; +}) => { + 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(groupItem => groupItem.target.name) + .toString(); + + const childRelations = getEntityRelations(group, RELATION_PARENT_OF, { + kind: 'group', + }); + + const displayName = profile?.displayName ?? name; + + if (!group) return User not found; + + return ( + } + subheader={description} + variant={variant} + > + + + + + + + {profile?.email && ( + + + + + + + {profile.email} + + )} + {parent ? ( + + + + + + + + + + + ) : null} + {childRelations?.length ? ( + + + + + + + + {childRelations.map((children, index) => ( + + ))} + + + ) : null} + + + + + ); +}; diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/index.ts b/plugins/org/src/components/Cards/Group/GroupProfile/index.ts new file mode 100644 index 0000000000..44efe25a50 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/GroupProfile/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 * from './GroupProfileCard'; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx new file mode 100644 index 0000000000..20fb3a10ec --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx @@ -0,0 +1,131 @@ +/* + * 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 { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { EntityContext, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { ApiProvider, ApiRegistry } from '@backstage/core-api'; + +import { MembersListCard } from '.'; + +export default { + title: 'Plugins/Org/Group Members List Card', + component: MembersListCard, +}; + +const makeUser = ({ + name, + uid, + displayName, + email, +}: { + name: string; + uid: string; + displayName: string; + email: string; +}) => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name, + uid, + }, + spec: { + profile: { + displayName, + email, + picture: `https://avatars.dicebear.com/api/avataaars/${email}.svg?background=%23fff`, + }, + }, + relations: [ + { + type: 'memberOf', + target: { + namespace: 'default', + kind: 'group', + name: 'team-a', + }, + }, + ], +}); + +const defaultEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, +}; + +const alice = makeUser({ + name: 'alice', + uid: '123', + displayName: 'Alice Doe', + email: 'alice@example.com', +}); +const bob = makeUser({ + name: 'bob', + uid: '456', + displayName: 'Bob Jones', + email: 'bob@example.com', +}); + +const catalogApi = (items: Entity[]) => ({ + getEntities: () => Promise.resolve({ items }), +}); + +const apiRegistry = (items: Entity[]) => + ApiRegistry.from([[catalogApiRef, catalogApi(items)]]); + +export const Default = () => ( + + + + + + + + + + + +); + +export const Empty = () => ( + + + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx new file mode 100644 index 0000000000..28c55b376b --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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 { MembersListCard } from './MembersListCard'; + +describe('MemberTab Test', () => { + const groupEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-d', + description: 'The evil-corp organization', + namespace: 'default', + }, + spec: { + type: 'team', + parent: 'boxoffice', + children: [], + }, + }; + + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'tara.macgovern', + namespace: 'default', + uid: 'a5gerth56', + }, + relations: [ + { + type: 'memberOf', + target: { + kind: 'group', + name: 'team-d', + namespace: 'default', + }, + }, + ], + spec: { + profile: { + displayName: 'Tara MacGovern', + email: 'tara-macgovern@example.com', + picture: 'https://example.com/staff/tara.jpeg', + }, + memberOf: ['team-d'], + }, + }, + ] as Entity[], + }), + }; + + const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]); + + it('Display Profile Card', async () => { + const rendered = await renderWithEffects( + wrapInTestApp( + + + + + , + , + ), + ); + + expect(rendered.getByAltText('Tara MacGovern')).toHaveAttribute( + 'src', + 'https://example.com/staff/tara.jpeg', + ); + expect( + rendered.getByText('tara-macgovern@example.com'), + ).toBeInTheDocument(); + expect(rendered.getByText('Tara MacGovern')).toHaveAttribute( + 'href', + '/catalog/default/user/tara.macgovern', + ); + }); +}); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx new file mode 100644 index 0000000000..e60e22a8f2 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + GroupEntity, + RELATION_MEMBER_OF, + UserEntity, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; +import { + useEntity, + catalogApiRef, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; +import { + Box, + createStyles, + Grid, + Link, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { useAsync } from 'react-use'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + overflow: 'visible', + position: 'relative', + margin: theme.spacing(4, 1, 1), + flex: '1', + minWidth: '0px', + }, + }), +); + +const MemberComponent = ({ + member, + groupEntity, +}: { + member: UserEntity; + groupEntity: Entity; +}) => { + const classes = useStyles(); + const { + metadata: { name: metaName }, + spec: { profile }, + } = member; + const displayName = profile?.displayName ?? metaName; + + return ( + + + + + + + + {displayName} + + + {profile?.email} + + + + + ); +}; + +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 }, + } = groupEntity; + const catalogApi = useApi(catalogApiRef); + + const displayName = profile?.displayName ?? groupName; + + const { loading, error, value: members } = useAsync(async () => { + const membersList = await catalogApi.getEntities({ + filter: { + kind: 'User', + }, + }); + const groupMembersList = ((membersList.items as unknown) as Array< + UserEntity + >).filter(member => + member?.relations?.some( + r => + r.type === RELATION_MEMBER_OF && + r.target.name.toLowerCase() === groupName.toLowerCase(), + ), + ); + return groupMembersList; + }, [catalogApi]); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + return ( + + + + {members && members.length ? ( + members.map(member => ( + + )) + ) : ( + + This group has no members. + + )} + + + + ); +}; diff --git a/plugins/org/src/components/Cards/Group/MembersList/index.ts b/plugins/org/src/components/Cards/Group/MembersList/index.ts new file mode 100644 index 0000000000..c3f4ea9178 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/MembersList/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 * from './MembersListCard'; diff --git a/plugins/org/src/components/Cards/Group/index.ts b/plugins/org/src/components/Cards/Group/index.ts new file mode 100644 index 0000000000..a011891f62 --- /dev/null +++ b/plugins/org/src/components/Cards/Group/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 './MembersList'; +export * from './GroupProfile'; diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx new file mode 100644 index 0000000000..bce1240434 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx @@ -0,0 +1,136 @@ +/* + * 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 { Grid, ThemeProvider } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { GroupEntity } from '@backstage/catalog-model'; +import { + createTheme, + genPageTheme, + shapes, + BackstageTheme, +} from '@backstage/theme'; +import { + EntityContext, + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-react'; +import { ApiProvider, ApiRegistry } from '@backstage/core-api'; + +import { OwnershipCard } from '.'; + +export default { + title: 'Plugins/Org/Ownership Card', + component: OwnershipCard, +}; + +const defaultEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, +}; + +const makeComponent = ({ type, name }: { type: string; name: string }) => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + }, + spec: { + type, + }, + relations: [ + { + type: 'ownedBy', + target: { + namespace: 'default', + kind: 'Group', + name: 'team-a', + }, + }, + ], +}); + +const serviceA = makeComponent({ type: 'service', name: 'service-a' }); +const serviceB = makeComponent({ type: 'service', name: 'service-a' }); +const websiteA = makeComponent({ type: 'website', name: 'website-a' }); + +const catalogApi: Partial = { + getEntities: () => Promise.resolve({ items: [serviceA, serviceB, websiteA] }), +}; + +const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); + +export const Default = () => ( + + + + + + + + + + + +); + +const monochromeTheme = (outer: BackstageTheme) => + createTheme({ + ...outer, + defaultPageTheme: 'home', + pageTheme: { + home: genPageTheme(['#444'], shapes.wave2), + documentation: genPageTheme(['#474747'], shapes.wave2), + tool: genPageTheme(['#222'], shapes.wave2), + service: genPageTheme(['#aaa'], shapes.wave2), + website: genPageTheme(['#0e0e0e'], shapes.wave2), + library: genPageTheme(['#9d9d9d'], shapes.wave2), + other: genPageTheme(['#aaa'], shapes.wave2), + app: genPageTheme(['#666'], shapes.wave2), + }, + }); + +export const Themed = () => ( + + + + + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx new file mode 100644 index 0000000000..0c46b8e670 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -0,0 +1,206 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { InfoCard, InfoCardVariants, Progress, useApi } from '@backstage/core'; +import { + catalogApiRef, + isOwnerOf, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { BackstageTheme, genPageTheme } from '@backstage/theme'; +import { + Box, + createStyles, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; + +type EntitiesKinds = 'Component' | 'API'; +type EntitiesTypes = + | 'service' + | 'website' + | 'library' + | 'documentation' + | 'api' + | 'tool'; + +const createPageTheme = ( + theme: BackstageTheme, + shapeKey: string, + colorsKey: string, +) => { + const { colors } = theme.getPageTheme({ themeId: colorsKey }); + const { shape } = theme.getPageTheme({ themeId: shapeKey }); + return genPageTheme(colors, shape).backgroundImage; +}; + +const useStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + padding: theme.spacing(2), + color: '#fff', + transition: `${theme.transitions.duration.standard}ms`, + '&:hover': { + boxShadow: theme.shadows[4], + }, + }, + bold: { + fontWeight: theme.typography.fontWeightBold, + }, + service: { + background: createPageTheme(theme, 'home', 'service'), + }, + website: { + background: createPageTheme(theme, 'home', 'website'), + }, + library: { + background: createPageTheme(theme, 'home', 'library'), + }, + documentation: { + background: createPageTheme(theme, 'home', 'documentation'), + }, + api: { + background: createPageTheme(theme, 'home', 'home'), + }, + tool: { + background: createPageTheme(theme, 'home', 'tool'), + }, + }), +); + +const countEntitiesBy = ( + entities: Array, + kind: EntitiesKinds, + type?: EntitiesTypes, +) => + entities.filter( + e => e.kind === kind && (type ? e?.spec?.type === type : true), + ).length; + +const EntityCountTile = ({ + counter, + className, + name, +}: { + counter: number; + className: EntitiesTypes; + name: string; +}) => { + const classes = useStyles(); + return ( + + + {counter} + + + {name} + + + ); +}; + +export const OwnershipCard = ({ + variant, +}: { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + variant?: InfoCardVariants; +}) => { + const { entity } = useEntity(); + const catalogApi = useApi(catalogApiRef); + const { + loading, + error, + value: componentsWithCounters, + } = useAsync(async () => { + const entitiesList = await catalogApi.getEntities(); + const ownedEntitiesList = entitiesList.items.filter(component => + isOwnerOf(entity, component), + ); + + return [ + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'service'), + className: 'service', + name: 'Services', + }, + { + counter: countEntitiesBy( + ownedEntitiesList, + 'Component', + 'documentation', + ), + className: 'documentation', + name: 'Documentation', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'API'), + className: 'api', + name: 'APIs', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'library'), + className: 'library', + name: 'Libraries', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'website'), + className: 'website', + name: 'Websites', + }, + { + counter: countEntitiesBy(ownedEntitiesList, 'Component', 'tool'), + className: 'tool', + name: 'Tools', + }, + ] as Array<{ counter: number; className: EntitiesTypes; name: string }>; + }, [catalogApi]); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + return ( + + + {componentsWithCounters?.map(c => ( + + + + ))} + + + ); +}; diff --git a/plugins/org/src/components/Cards/OwnershipCard/index.ts b/plugins/org/src/components/Cards/OwnershipCard/index.ts new file mode 100644 index 0000000000..1fa1bb4044 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/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 * from './OwnershipCard'; diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx new file mode 100644 index 0000000000..b30427e923 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { UserEntity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { UserProfileCard } from '.'; + +export default { + title: 'Plugins/Org/User Profile Card', + component: UserProfileCard, +}; + +const dummyGroup = { + type: 'memberOf', + target: { + namespace: 'default', + kind: 'group', + name: 'team-a', + }, +}; + +const defaultEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + }, + spec: { + profile: { + displayName: 'Guest User', + email: 'guest@example.com', + picture: + 'https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff', + }, + memberOf: ['team-a'], + }, + relations: [dummyGroup], +}; + +export const Default = () => ( + + + + + + + + + +); + +const noImageEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + }, + spec: { + profile: { + displayName: 'Guest User', + email: 'guest@example.com', + }, + memberOf: ['team-a'], + }, + relations: [dummyGroup], +}; + +export const NoImage = () => ( + + + + + + + + + +); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx new file mode 100644 index 0000000000..7c7d26c228 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.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 { 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'; + +describe('UserSummary Test', () => { + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'calum.leavy', + }, + spec: { + profile: { + displayName: 'Calum Leavy', + email: 'calum-leavy@example.com', + picture: 'https://example.com/staff/calum.jpeg', + }, + memberOf: ['ExampleGroup'], + }, + relations: [ + { + type: 'memberOf', + target: { + kind: 'group', + name: 'ExampleGroup', + namespace: 'default', + }, + }, + ], + }; + + it('Display Profile Card', async () => { + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument(); + expect(rendered.getByAltText('Calum Leavy')).toHaveAttribute( + 'src', + 'https://example.com/staff/calum.jpeg', + ); + expect(rendered.getByText('[ExampleGroup]')).toHaveAttribute( + 'href', + '/catalog/default/group/ExampleGroup', + ); + }); +}); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx new file mode 100644 index 0000000000..cf558fd55d --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Entity, + RELATION_MEMBER_OF, + UserEntity, +} from '@backstage/catalog-model'; +import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core'; +import { entityRouteParams, useEntity } from '@backstage/plugin-catalog-react'; +import { + Box, + Grid, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, +} from '@material-ui/core'; +import EmailIcon from '@material-ui/icons/Email'; +import GroupIcon from '@material-ui/icons/Group'; +import PersonIcon from '@material-ui/icons/Person'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const GroupLink = ({ + groupName, + index, + entity, +}: { + groupName: string; + index: number; + entity: Entity; +}) => ( + <> + {index >= 1 ? ', ' : ''} + + [{groupName}] + + +); + +const CardTitle = ({ title }: { title?: string }) => + title ? ( + + + {title} + + ) : null; + +export const UserProfileCard = ({ + variant, +}: { + /** @deprecated The entity is now grabbed from context instead */ + entity?: UserEntity; + variant?: InfoCardVariants; +}) => { + const user = useEntity().entity as UserEntity; + const { + metadata: { name: metaName }, + spec: { profile }, + } = user; + const groupNames = + user?.relations + ?.filter(r => r.type === RELATION_MEMBER_OF) + ?.map(group => group.target.name) || []; + const displayName = profile?.displayName ?? metaName; + + if (!user) { + return User not found; + } + + const emailHref = profile?.email ? `mailto:${profile.email}` : ''; + + return ( + } variant={variant}> + + + + + + + + {profile?.email && ( + + + + + + {profile.email} + + + )} + + + + + + + + + {groupNames.map((groupName, index) => ( + + ))} + + + + + + + ); +}; diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/index.ts b/plugins/org/src/components/Cards/User/UserProfileCard/index.ts new file mode 100644 index 0000000000..dc5e2902b7 --- /dev/null +++ b/plugins/org/src/components/Cards/User/UserProfileCard/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 * from './UserProfileCard'; diff --git a/plugins/org/src/components/Cards/User/index.ts b/plugins/org/src/components/Cards/User/index.ts new file mode 100644 index 0000000000..dc5e2902b7 --- /dev/null +++ b/plugins/org/src/components/Cards/User/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 * from './UserProfileCard'; diff --git a/plugins/org/src/components/Cards/index.ts b/plugins/org/src/components/Cards/index.ts new file mode 100644 index 0000000000..62f63da3d4 --- /dev/null +++ b/plugins/org/src/components/Cards/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './Group'; +export * from './User'; +export * from './OwnershipCard'; diff --git a/plugins/org/src/components/index.ts b/plugins/org/src/components/index.ts new file mode 100644 index 0000000000..975f66bd25 --- /dev/null +++ b/plugins/org/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 './Cards'; diff --git a/plugins/org/src/index.ts b/plugins/org/src/index.ts new file mode 100644 index 0000000000..36c5f94ee5 --- /dev/null +++ b/plugins/org/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + 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 new file mode 100644 index 0000000000..e488422441 --- /dev/null +++ b/plugins/org/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 { orgPlugin } from './plugin'; + +describe('groups', () => { + it('should export plugin', () => { + expect(orgPlugin).toBeDefined(); + }); +}); diff --git a/plugins/org/src/plugin.ts b/plugins/org/src/plugin.ts new file mode 100644 index 0000000000..195e88ea41 --- /dev/null +++ b/plugins/org/src/plugin.ts @@ -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 { createComponentExtension, createPlugin } from '@backstage/core'; + +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/org/src/setupTests.ts b/plugins/org/src/setupTests.ts new file mode 100644 index 0000000000..43b8421558 --- /dev/null +++ b/plugins/org/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/pagerduty/.eslintrc.js b/plugins/pagerduty/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/pagerduty/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md new file mode 100644 index 0000000000..85547d08c7 --- /dev/null +++ b/plugins/pagerduty/CHANGELOG.md @@ -0,0 +1,124 @@ +# @backstage/plugin-pagerduty + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.0 + +### Minor Changes + +- 549a859ac: Improved the UI of the pagerduty plugin, and added a standalone TriggerButton + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.8 + +### Patch Changes + +- 29a138636: Use the Luxon Date Library to follow the recommendations of ADR010. +- b288a291e: Migrated to new composability API, exporting the plugin instance as `pagerDutyPlugin`, entity card as `EntityPagerDutyCard`, and entity conditional as `isPagerDutyAvailable`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/core@0.4.2 + +## 0.2.3 + +### Patch Changes + +- 3b50f833d: Supporting Timezones +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + +## 0.2.2 + +### Patch Changes + +- 6011b7d3e: Added pagerduty plugin to example app +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md new file mode 100644 index 0000000000..1c22053b88 --- /dev/null +++ b/plugins/pagerduty/README.md @@ -0,0 +1,82 @@ +# PagerDuty + +## Overview + +This plugin displays PagerDuty information about an entity such as if there are any active incidents and what the escalation policy is. + +There is also an easy way to trigger an alarm directly to the person who is currently on-call. + +This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document. + +This plugin provides: + +- A list of incidents +- A way to trigger an alarm to the person on-call +- Information details about the person on-call + +## Setup instructions + +Install the plugin: + +```bash +yarn add @backstage/plugin-pagerduty +``` + +Add it to the app in `plugins.ts`: + +```ts +export { plugin as Pagerduty } from '@backstage/plugin-pagerduty'; +``` + +Add it to the `EntityPage.ts`: + +```ts +import { + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from '@backstage/plugin-pagerduty'; +// add to code +{ + isPagerDutyAvailable(entity) && ( + + + + ); +} +``` + +## Client configuration + +If you want to override the default URL for events, you can add it to `app-config.yaml`. + +In `app-config.yaml`: + +```yaml +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' +``` + +## Providing the API Token + +In order for the client to make requests to the [PagerDuty API](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/) it needs an [API Token](https://support.pagerduty.com/docs/generating-api-keys#generating-a-general-access-rest-api-key). + +Then start the backend passing the token as an environment variable: + +```bash +$ PAGERDUTY_TOKEN='Token token=' yarn start +``` + +This will proxy the request by adding `Authorization` header with the provided token. + +## Integration Key + +The information displayed for each entity is based on the [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). + +### Adding the integration key to the entity annotation + +If you want to use this plugin for an entity, you need to label it with the below annotation: + +```yml +annotations: + pagerduty.com/integration-key: [INTEGRATION_KEY] +``` diff --git a/plugins/pagerduty/dev/index.tsx b/plugins/pagerduty/dev/index.tsx new file mode 100644 index 0000000000..ad46c0ca9e --- /dev/null +++ b/plugins/pagerduty/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 { pagerDutyPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(pagerDutyPlugin).render(); diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json new file mode 100644 index 0000000000..949d7aea6b --- /dev/null +++ b/plugins/pagerduty/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/plugin-pagerduty", + "version": "0.3.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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/pagerduty" + }, + "keywords": [ + "backstage", + "pagerduty" + ], + "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.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", + "@types/react": "^16.9", + "classnames": "^2.2.6", + "luxon": "1.25.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.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@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", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts new file mode 100644 index 0000000000..d1220f715f --- /dev/null +++ b/plugins/pagerduty/src/api/client.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core'; +import { Service, Incident, OnCall } from '../components/types'; +import { + PagerDutyApi, + TriggerAlarmRequest, + ServicesResponse, + IncidentsResponse, + OnCallsResponse, + ClientApiConfig, + RequestOptions, +} from './types'; + +export class UnauthorizedError extends Error {} + +export const pagerDutyApiRef = createApiRef({ + id: 'plugin.pagerduty.api', + description: 'Used to fetch data from PagerDuty API', +}); + +export class PagerDutyClient implements PagerDutyApi { + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const eventsBaseUrl: string = + configApi.getOptionalString('pagerDuty.eventsBaseUrl') ?? + 'https://events.pagerduty.com/v2'; + return new PagerDutyClient({ + eventsBaseUrl, + discoveryApi, + }); + } + constructor(private readonly config: ClientApiConfig) {} + + async getServiceByIntegrationKey(integrationKey: string): Promise { + const params = `time_zone=UTC&include[]=integrations&include[]=escalation_policies&query=${integrationKey}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/services?${params}`; + const { services } = await this.getByUrl(url); + + return services; + } + + async getIncidentsByServiceId(serviceId: string): Promise { + const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/incidents?${params}`; + const { incidents } = await this.getByUrl(url); + + return incidents; + } + + async getOnCallByPolicyId(policyId: string): Promise { + const params = `time_zone=UTC&include[]=users&escalation_policy_ids[]=${policyId}`; + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/pagerduty/oncalls?${params}`; + const { oncalls } = await this.getByUrl(url); + + return oncalls; + } + + triggerAlarm({ + integrationKey, + source, + description, + userName, + }: TriggerAlarmRequest): Promise { + const body = JSON.stringify({ + event_action: 'trigger', + routing_key: integrationKey, + client: 'Backstage', + client_url: source, + payload: { + summary: description, + source: source, + severity: 'error', + class: 'manual trigger', + custom_details: { + user: userName, + }, + }, + }); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Accept: 'application/json, text/plain, */*', + }, + body, + }; + + const url = this.config.eventsBaseUrl ?? 'https://events.pagerduty.com/v2'; + + return this.request(`${url}/enqueue`, options); + } + + private async getByUrl(url: string): Promise { + const options = { + method: 'GET', + headers: { + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + const response = await this.request(url, options); + + return response.json(); + } + + private async request( + url: string, + options: RequestOptions, + ): Promise { + const response = await fetch(url, options); + if (response.status === 401) { + throw new UnauthorizedError(); + } + if (!response.ok) { + const payload = await response.json(); + const errors = payload.errors.map((error: string) => error).join(' '); + const message = `Request failed with ${response.status}, ${errors}`; + throw new Error(message); + } + return response; + } +} diff --git a/plugins/pagerduty/src/api/index.ts b/plugins/pagerduty/src/api/index.ts new file mode 100644 index 0000000000..90604c4012 --- /dev/null +++ b/plugins/pagerduty/src/api/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 { PagerDutyClient, pagerDutyApiRef, UnauthorizedError } from './client'; +export type { PagerDutyApi } from './types'; diff --git a/plugins/pagerduty/src/api/types.ts b/plugins/pagerduty/src/api/types.ts new file mode 100644 index 0000000000..733f171489 --- /dev/null +++ b/plugins/pagerduty/src/api/types.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 { Incident, OnCall, Service } from '../components/types'; +import { DiscoveryApi } from '@backstage/core'; + +export type TriggerAlarmRequest = { + integrationKey: string; + source: string; + description: string; + userName: string; +}; + +export interface PagerDutyApi { + /** + * Fetches a list of services, filtered by the provided integration key. + * + */ + getServiceByIntegrationKey(integrationKey: string): Promise; + + /** + * Fetches a list of incidents a provided service has. + * + */ + getIncidentsByServiceId(serviceId: string): Promise; + + /** + * Fetches the list of users in an escalation policy. + * + */ + getOnCallByPolicyId(policyId: string): Promise; + + /** + * Triggers an incident to whoever is on-call. + */ + triggerAlarm(request: TriggerAlarmRequest): Promise; +} + +export type ServicesResponse = { + services: Service[]; +}; + +export type IncidentsResponse = { + incidents: Incident[]; +}; + +export type OnCallsResponse = { + oncalls: OnCall[]; +}; + +export type ClientApiConfig = { + eventsBaseUrl?: string; + discoveryApi: DiscoveryApi; +}; + +export type RequestOptions = { + method: string; + headers: HeadersInit; + body?: BodyInit; +}; diff --git a/plugins/pagerduty/src/assets/emptystate.svg b/plugins/pagerduty/src/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/pagerduty/src/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx new file mode 100644 index 0000000000..c22552b7c6 --- /dev/null +++ b/plugins/pagerduty/src/components/Errors/MissingTokenError.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { EmptyState } from '@backstage/core'; +import { Button } from '@material-ui/core'; + +export const MissingTokenError = () => ( + + Read More + + } + /> +); diff --git a/plugins/pagerduty/src/components/Errors/index.ts b/plugins/pagerduty/src/components/Errors/index.ts new file mode 100644 index 0000000000..3c2dfa65f2 --- /dev/null +++ b/plugins/pagerduty/src/components/Errors/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 { MissingTokenError } from './MissingTokenError'; diff --git a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx new file mode 100644 index 0000000000..15ff3278a0 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { EscalationPolicy } from './EscalationPolicy'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { User } from '../types'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; + +const mockPagerDutyApi = { + getOnCallByPolicyId: () => [], +}; +const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); + +describe('Escalation', () => { + it('Handles an empty response', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('456'); + }); + + it('Render a list of users', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockImplementationOnce(async () => [ + { + user: { + name: 'person1', + id: 'p1', + summary: 'person1', + email: 'person1@example.com', + html_url: 'http://a.com/id1', + } as User, + }, + ]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('person1')).toBeInTheDocument(); + expect(getByText('person1@example.com')).toBeInTheDocument(); + expect(mockPagerDutyApi.getOnCallByPolicyId).toHaveBeenCalledWith('abc'); + }); + + it('Handles errors', async () => { + mockPagerDutyApi.getOnCallByPolicyId = jest + .fn() + .mockRejectedValueOnce(new Error('Error message')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText('Error encountered while fetching information. Error message'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx new file mode 100644 index 0000000000..6fd036330a --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { EscalationUsersEmptyState } from './EscalationUsersEmptyState'; +import { EscalationUser } from './EscalationUser'; +import { useAsync } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + policyId: string; +}; + +export const EscalationPolicy = ({ policyId }: Props) => { + const api = useApi(pagerDutyApiRef); + + const { value: users, loading, error } = useAsync(async () => { + const oncalls = await api.getOnCallByPolicyId(policyId); + const usersItem = oncalls + .sort((a, b) => a.escalation_level - b.escalation_level) + .map(oncall => oncall.user); + + return usersItem; + }); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!users?.length) { + return ; + } + + return ( + ON CALL}> + {users!.map((user, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx new file mode 100644 index 0000000000..41995c86f5 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUser.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import Avatar from '@material-ui/core/Avatar'; +import EmailIcon from '@material-ui/icons/Email'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { User } from '../types'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, +}); + +type Props = { + user: User; +}; + +export const EscalationUser = ({ user }: Props) => { + const classes = useStyles(); + + return ( + + + + + + {user.name} + + } + secondary={user.email} + /> + + + + + + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx new file mode 100644 index 0000000000..d587011601 --- /dev/null +++ b/plugins/pagerduty/src/components/Escalation/EscalationUsersEmptyState.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { StatusWarning } from '@backstage/core'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const EscalationUsersEmptyState = () => { + const classes = useStyles(); + return ( + + +
+ +
+
+ +
+ ); +}; diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts b/plugins/pagerduty/src/components/Escalation/index.ts similarity index 91% rename from plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts rename to plugins/pagerduty/src/components/Escalation/index.ts index ddc146dbd8..ac2db62cd9 100644 --- a/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts +++ b/plugins/pagerduty/src/components/Escalation/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { IconLinkVertical } from './IconLinkVertical'; +export { EscalationPolicy } from './EscalationPolicy'; diff --git a/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx new file mode 100644 index 0000000000..f7a0398c55 --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentEmptyState.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import EmptyStateImage from '../../assets/emptystate.svg'; + +export const IncidentsEmptyState = () => { + return ( + + + Nice! No incidents found! + + + EmptyState + + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx new file mode 100644 index 0000000000..3d2e4d0edd --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Link, + Typography, + Chip, +} from '@material-ui/core'; +import Done from '@material-ui/icons/Done'; +import Warning from '@material-ui/icons/Warning'; +import { DateTime, Duration } from 'luxon'; +import { Incident } from '../types'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles(theme => ({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + listItemPrimary: { + fontWeight: 'bold', + }, + warning: { + borderColor: theme.palette.status.warning, + color: theme.palette.status.warning, + '& *': { + color: theme.palette.status.warning, + }, + }, + error: { + borderColor: theme.palette.status.error, + color: theme.palette.status.error, + '& *': { + color: theme.palette.status.error, + }, + }, +})); + +type Props = { + incident: Incident; +}; + +export const IncidentListItem = ({ incident }: Props) => { + const classes = useStyles(); + const duration = + new Date().getTime() - new Date(incident.created_at).getTime(); + const createdAt = DateTime.local() + .minus(Duration.fromMillis(duration)) + .toRelative({ locale: 'en' }); + const user = incident.assignments[0]?.assignee; + + return ( + + + : } + className={ + incident.status === 'triggered' + ? classes.error + : classes.warning + } + /> + {incident.title} + + } + primaryTypographyProps={{ + variant: 'body1', + className: classes.listItemPrimary, + }} + secondary={ + + Created {createdAt} and assigned to{' '} + + {user?.summary ?? 'nobody'} + + + } + /> + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx new file mode 100644 index 0000000000..88500fbe9b --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Incidents } from './Incidents'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; +import { Incident } from '../types'; + +const mockPagerDutyApi = { + getIncidentsByServiceId: () => [], +}; +const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); + +describe('Incidents', () => { + it('Renders an empty state when there are no incidents', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + }); + + it('Renders all incidents', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest.fn().mockImplementationOnce( + async () => + [ + { + id: 'id1', + status: 'triggered', + title: 'title1', + created_at: '2020-11-06T00:00:00Z', + assignments: [ + { + assignee: { + id: 'p1', + summary: 'person1', + html_url: 'http://a.com/id1', + }, + }, + ], + html_url: 'http://a.com/id1', + serviceId: 'sId1', + }, + { + id: 'id2', + status: 'acknowledged', + title: 'title2', + created_at: '2020-11-07T00:00:00Z', + assignments: [ + { + assignee: { + id: 'p2', + summary: 'person2', + + html_url: 'http://a.com/id2', + }, + }, + ], + html_url: 'http://a.com/id2', + serviceId: 'sId2', + }, + ] as Incident[], + ); + const { getByText, getAllByTitle, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('title1')).toBeInTheDocument(); + expect(getByText('title2')).toBeInTheDocument(); + expect(getByText('person1')).toBeInTheDocument(); + expect(getByText('person2')).toBeInTheDocument(); + expect(getByText('triggered')).toBeInTheDocument(); + expect(getByText('acknowledged')).toBeInTheDocument(); + expect(queryByTestId('chip-triggered')).toBeInTheDocument(); + expect(queryByTestId('chip-acknowledged')).toBeInTheDocument(); + + // assert links, mailto and hrefs, date calculation + expect(getAllByTitle('View in PagerDuty').length).toEqual(2); + }); + + it('Handle errors', async () => { + mockPagerDutyApi.getIncidentsByServiceId = jest + .fn() + .mockRejectedValueOnce(new Error('Error occurred')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/Incident/Incidents.tsx b/plugins/pagerduty/src/components/Incident/Incidents.tsx new file mode 100644 index 0000000000..732a582cdb --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/Incidents.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { IncidentListItem } from './IncidentListItem'; +import { IncidentsEmptyState } from './IncidentEmptyState'; +import { useAsyncFn } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + serviceId: string; + refreshIncidents: boolean; +}; + +export const Incidents = ({ serviceId, refreshIncidents }: Props) => { + const api = useApi(pagerDutyApiRef); + + const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn( + async () => await api.getIncidentsByServiceId(serviceId), + ); + + useEffect(() => { + getIncidents(); + }, [refreshIncidents, getIncidents]); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!incidents?.length) { + return ; + } + + return ( + INCIDENTS}> + {incidents!.map((incident, index) => ( + + ))} + + ); +}; diff --git a/plugins/pagerduty/src/components/Incident/index.ts b/plugins/pagerduty/src/components/Incident/index.ts new file mode 100644 index 0000000000..fb2702602b --- /dev/null +++ b/plugins/pagerduty/src/components/Incident/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 { Incidents } from './Incidents'; diff --git a/plugins/pagerduty/src/components/PagerDutyCard.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx new file mode 100644 index 0000000000..a719a27f86 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, 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, + ApiProvider, + ApiRegistry, + createApiRef, +} from '@backstage/core'; +import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../api'; +import { Service } from './types'; + +const mockPagerDutyApi: Partial = { + getServiceByIntegrationKey: async () => [], + getOnCallByPolicyId: async () => [], + getIncidentsByServiceId: async () => [], +}; + +const apis = ApiRegistry.from([ + [pagerDutyApiRef, mockPagerDutyApi], + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], +]); +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + }, + }, +}; + +const service: Service = { + id: 'abc', + name: 'pagerduty-name', + html_url: 'www.example.com', + escalation_policy: { + id: 'def', + user: { + name: 'person1', + id: 'p1', + summary: 'person1', + email: 'person1@example.com', + html_url: 'http://a.com/id1', + }, + }, + integrationKey: 'abcd', +}; + +describe('PageDutyCard', () => { + it('Render pagerduty', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockImplementationOnce(async () => [service]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Missing or invalid PagerDuty Token')).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + it('opens the dialog when trigger button is clicked', async () => { + mockPagerDutyApi.getServiceByIntegrationKey = jest + .fn() + .mockImplementationOnce(async () => [service]); + + const { getByText, queryByTestId, getByTestId, getByRole } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx new file mode 100644 index 0000000000..7ec425a062 --- /dev/null +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { 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 { Card, CardHeader, Divider, CardContent } from '@material-ui/core'; +import { Incidents } from './Incident'; +import { EscalationPolicy } from './Escalation'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { pagerDutyApiRef, UnauthorizedError } from '../api'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import { MissingTokenError } from './Errors/MissingTokenError'; +import WebIcon from '@material-ui/icons/Web'; +import { PAGERDUTY_INTEGRATION_KEY } from './constants'; +import { TriggerButton, useShowDialog } from './TriggerButton'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]); + +export const PagerDutyCard = () => { + const { entity } = useEntity(); + const api = useApi(pagerDutyApiRef); + const [refreshIncidents, setRefreshIncidents] = useState(false); + const integrationKey = entity.metadata.annotations![ + PAGERDUTY_INTEGRATION_KEY + ]; + const setShowDialog = useShowDialog()[1]; + + const showDialog = useCallback(() => { + setShowDialog(true); + }, [setShowDialog]); + + const handleRefresh = useCallback(() => { + setRefreshIncidents(x => !x); + }, []); + + const { value: service, loading, error } = useAsync(async () => { + const services = await api.getServiceByIntegrationKey(integrationKey); + + return { + id: services[0].id, + name: services[0].name, + url: services[0].html_url, + policyId: services[0].escalation_policy.id, + }; + }); + + if (error instanceof UnauthorizedError) { + return ; + } + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + const serviceLink = { + label: 'Service Directory', + href: service!.url, + icon: , + }; + + const triggerLink = { + label: 'Create Incident', + action: , + icon: , + }; + + return ( + + } + /> + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/TriggerButton/index.tsx b/plugins/pagerduty/src/components/TriggerButton/index.tsx new file mode 100644 index 0000000000..833e01d8aa --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerButton/index.tsx @@ -0,0 +1,94 @@ +/* + * 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, { useCallback, PropsWithChildren } from 'react'; +import { createGlobalState } from 'react-use'; +import { makeStyles, Button } from '@material-ui/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { BackstageTheme } from '@backstage/theme'; + +import { TriggerDialog } from '../TriggerDialog'; +import { PAGERDUTY_INTEGRATION_KEY } from '../constants'; + +export interface TriggerButtonProps { + design: 'link' | 'button'; + onIncidentCreated?: () => void; +} + +const useStyles = makeStyles(theme => ({ + buttonStyle: { + backgroundColor: theme.palette.error.main, + color: theme.palette.error.contrastText, + '&:hover': { + backgroundColor: theme.palette.error.dark, + }, + }, + triggerAlarm: { + paddingTop: 0, + paddingBottom: 0, + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + lineHeight: 1.5, + '&:hover, &:focus, &.focus': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }, +})); + +export const useShowDialog = createGlobalState(false); + +export function TriggerButton({ + design, + onIncidentCreated, + children, +}: PropsWithChildren) { + const { buttonStyle, triggerAlarm } = useStyles(); + const { entity } = useEntity(); + const [dialogShown = false, setDialogShown] = useShowDialog(); + + const showDialog = useCallback(() => { + setDialogShown(true); + }, [setDialogShown]); + const hideDialog = useCallback(() => { + setDialogShown(false); + }, [setDialogShown]); + + const integrationKey = entity.metadata.annotations![ + PAGERDUTY_INTEGRATION_KEY + ]; + + return ( + <> + + + + ); +} diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx new file mode 100644 index 0000000000..8073ab44b5 --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + ApiRegistry, + alertApiRef, + createApiRef, + ApiProvider, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import { pagerDutyApiRef } from '../../api'; +import { Entity } from '@backstage/catalog-model'; +import { TriggerDialog } from './TriggerDialog'; + +describe('TriggerDialog', () => { + const mockIdentityApi: Partial = { + getUserId: () => 'guest@example.com', + }; + + const mockTriggerAlarmFn = jest.fn(); + const mockPagerDutyApi = { + triggerAlarm: mockTriggerAlarmFn, + }; + + const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [identityApiRef, mockIdentityApi], + [pagerDutyApiRef, mockPagerDutyApi], + ]); + + it('open the dialog and trigger an alarm', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + }, + }, + }; + + const { getByText, getByRole, getByTestId } = render( + wrapInTestApp( + + {}} + name={entity.metadata.name} + integrationKey="abc123" + onIncidentCreated={() => {}} + /> + , + ), + ); + + expect(getByRole('dialog')).toBeInTheDocument(); + expect( + getByText('This action will trigger an incident for ', { + exact: false, + }), + ).toBeInTheDocument(); + const input = getByTestId('trigger-input'); + const description = 'Test Trigger Alarm'; + await act(async () => { + fireEvent.change(input, { target: { value: description } }); + }); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(mockTriggerAlarmFn).toHaveBeenCalled(); + expect(mockTriggerAlarmFn).toHaveBeenCalledWith({ + integrationKey: entity!.metadata!.annotations![ + 'pagerduty.com/integration-key' + ], + source: window.location.toString(), + description, + userName: 'guest@example.com', + }); + }); +}); diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx new file mode 100644 index 0000000000..c2084a41ed --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -0,0 +1,149 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + TextField, + DialogActions, + Button, + DialogContent, + Typography, + CircularProgress, +} from '@material-ui/core'; +import { useApi, alertApiRef, identityApiRef } from '@backstage/core'; +import { useAsyncFn } from 'react-use'; +import { pagerDutyApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; + +type Props = { + name: string; + integrationKey: string; + showDialog: boolean; + handleDialog: () => void; + onIncidentCreated?: () => void; +}; + +export const TriggerDialog = ({ + name, + integrationKey, + showDialog, + handleDialog, + onIncidentCreated: onIncidentCreated, +}: Props) => { + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const api = useApi(pagerDutyApiRef); + const [description, setDescription] = useState(''); + + const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( + async (descriptions: string) => + await api.triggerAlarm({ + integrationKey, + source: window.location.toString(), + description: descriptions, + userName, + }), + ); + + const descriptionChanged = ( + event: React.ChangeEvent, + ) => { + setDescription(event.target.value); + }; + + useEffect(() => { + if (value) { + (async () => { + alertApi.post({ + message: `Alarm successfully triggered by ${userName}`, + }); + + handleDialog(); + + // The pager duty API isn't always returning the newly created alarm immediately + await new Promise(resolve => setTimeout(resolve, 1000)); + onIncidentCreated?.(); + })(); + } + }, [value, alertApi, handleDialog, userName, onIncidentCreated]); + + if (error) { + alertApi.post({ + message: `Failed to trigger alarm. ${error.message}`, + severity: 'error', + }); + } + + return ( + + + This action will trigger an incident for "{name}". + + + + + If the issue you are seeing does not need urgent attention, please + get in touch with the responsible team using their preferred + communications channel. You can find information about the owner of + this entity in the "About" card. If the issue is urgent, please + don't hesitate to trigger the alert. + + + + Please describe the problem you want to report. Be as descriptive as + possible. Your signed in user and a reference to the current page will + automatically be amended to the alarm so that the receiver can reach + out to you if necessary. + + + + + + + + + ); +}; diff --git a/plugins/pagerduty/src/components/TriggerDialog/index.ts b/plugins/pagerduty/src/components/TriggerDialog/index.ts new file mode 100644 index 0000000000..655cef8504 --- /dev/null +++ b/plugins/pagerduty/src/components/TriggerDialog/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 { TriggerDialog } from './TriggerDialog'; diff --git a/plugins/pagerduty/src/components/constants.ts b/plugins/pagerduty/src/components/constants.ts new file mode 100644 index 0000000000..a7c32a362e --- /dev/null +++ b/plugins/pagerduty/src/components/constants.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 const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key'; diff --git a/plugins/pagerduty/src/components/types.ts b/plugins/pagerduty/src/components/types.ts new file mode 100644 index 0000000000..ff63c81518 --- /dev/null +++ b/plugins/pagerduty/src/components/types.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. + */ + +export type Incident = { + id: string; + title: string; + status: string; + html_url: string; + assignments: [ + { + assignee: Assignee; + }, + ]; + serviceId: string; + created_at: string; +}; + +export type Service = { + id: string; + name: string; + html_url: string; + integrationKey: string; + escalation_policy: { + id: string; + user: User; + }; +}; + +export type OnCall = { + user: User; + escalation_level: number; +}; + +export type Assignee = { + id: string; + summary: string; + html_url: string; +}; + +export type User = { + id: string; + summary: string; + email: string; + html_url: string; + name: string; +}; + +export type SubHeaderLink = { + title: string; + href?: string; + icon: React.ReactNode; + action?: React.ReactNode; +}; diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts new file mode 100644 index 0000000000..92c3c8e73c --- /dev/null +++ b/plugins/pagerduty/src/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + pagerDutyPlugin, + pagerDutyPlugin as plugin, + EntityPagerDutyCard, +} from './plugin'; +export { + isPluginApplicableToEntity, + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from './components/PagerDutyCard'; +export { TriggerButton } from './components/TriggerButton'; +export { + PagerDutyClient, + pagerDutyApiRef, + UnauthorizedError, +} from './api/client'; diff --git a/plugins/pagerduty/src/plugin.test.ts b/plugins/pagerduty/src/plugin.test.ts new file mode 100644 index 0000000000..c1175ab384 --- /dev/null +++ b/plugins/pagerduty/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 { pagerDutyPlugin } from './plugin'; + +describe('pagerduty', () => { + it('should export plugin', () => { + expect(pagerDutyPlugin).toBeDefined(); + }); +}); diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts new file mode 100644 index 0000000000..fbe827b90d --- /dev/null +++ b/plugins/pagerduty/src/plugin.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + configApiRef, + createComponentExtension, +} from '@backstage/core'; +import { pagerDutyApiRef, PagerDutyClient } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '/pagerduty', + title: 'pagerduty', +}); + +export const pagerDutyPlugin = createPlugin({ + id: 'pagerduty', + apis: [ + createApiFactory({ + api: pagerDutyApiRef, + deps: { discoveryApi: discoveryApiRef, configApi: configApiRef }, + factory: ({ configApi, discoveryApi }) => + PagerDutyClient.fromConfig(configApi, discoveryApi), + }), + ], +}); + +export const EntityPagerDutyCard = pagerDutyPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/PagerDutyCard').then(m => m.PagerDutyCard), + }, + }), +); diff --git a/plugins/pagerduty/src/setupTests.ts b/plugins/pagerduty/src/setupTests.ts new file mode 100644 index 0000000000..0bfa67b49a --- /dev/null +++ b/plugins/pagerduty/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index 4f9d373fdc..0d9338fcc4 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @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 + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + ## 0.2.2 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 68c89169c5..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.2", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,15 @@ "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/proxy-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -19,8 +28,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.5.2", + "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -33,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@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/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 32e7a4d49d..d0eca55ee0 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -15,8 +15,8 @@ */ import { buildMiddleware, createRouter } from './router'; -import * as winston from 'winston'; import { + getVoidLogger, loadBackendConfig, SingleHostDiscovery, } from '@backstage/backend-common'; @@ -40,7 +40,7 @@ const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction< describe('createRouter', () => { it('works', async () => { - const logger = winston.createLogger(); + const logger = getVoidLogger(); const config = await loadBackendConfig({ logger, argv: [] }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ @@ -53,7 +53,7 @@ describe('createRouter', () => { }); describe('buildMiddleware', () => { - const logger = winston.createLogger(); + const logger = getVoidLogger(); beforeEach(() => { mockCreateProxyMiddleware.mockClear(); diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 56ece52c40..c37820a2c7 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,127 @@ # @backstage/plugin-register-component +## 0.2.11 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.2.10 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.2.9 + +### Patch Changes + +- 9ec66c345: Migrated to new composability API, exporting the plugin instance as `registerComponentPlugin`, and page as `RegisterComponentPage`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 1517876fd: Register component plugin is deprecated in favor of @backstage/plugin-catalog-import +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [87c0c53c2] + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.3 ### Patch Changes diff --git a/plugins/register-component/README.md b/plugins/register-component/README.md index d324b3941f..0abf27590f 100644 --- a/plugins/register-component/README.md +++ b/plugins/register-component/README.md @@ -1,5 +1,7 @@ # Register component plugin +> This plugin is deprecated in favor of [`@backstage/catalog-import`](https://github.com/backstage/backstage/tree/master/plugins/catalog-import), and will be soon removed from the project. + Welcome to the register-component plugin! This plugin allows you to submit your Backstage component using your software's YAML config. 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 18a97e36f7..daa9a64389 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.3", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/register-component" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", @@ -36,11 +45,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 6b097085e9..9dfed8f404 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -20,12 +20,11 @@ import { createRouteRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; -import { render, screen } from '@testing-library/react'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import { RegisterComponentPage } from './RegisterComponentPage'; const errorApi: jest.Mocked = { @@ -44,30 +43,29 @@ const catalogApi: jest.Mocked = { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - - - {children} - - + + {children} + ); describe('RegisterComponentPage', () => { - it('should render', () => { - render( - , - { wrapper: Wrapper }, + it('should render', async () => { + const { getByText } = await renderInTestApp( + + + , ); - expect(screen.getByText('Register existing component')).toBeInTheDocument(); + expect(getByText('Register existing component')).toBeInTheDocument(); }); }); 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/components/Router.tsx b/plugins/register-component/src/components/Router.tsx index eee7fd3806..16aa1fa695 100644 --- a/plugins/register-component/src/components/Router.tsx +++ b/plugins/register-component/src/components/Router.tsx @@ -20,6 +20,13 @@ import { RouteRef } from '@backstage/core'; // As we don't know which path the catalog's router mounted on // We need to inject this from the app + +/** + * Provides a router for registering a component. + * + * @deprecated The router for this component is deprecated and replaced with the `catalog-import` plugin. + * @see https://github.com/backstage/backstage/tree/master/plugins/catalog-import + */ export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => ( { 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 8aba667366..08b4b9da77 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,38 @@ # @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 + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + ## 0.1.4 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index edaaa3a9b5..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.4", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/rollbar-backend" + }, + "keywords": [ + "backstage", + "rollbar" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/config": "^0.1.1", + "@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", @@ -37,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar-backend/src/service/router.test.ts b/plugins/rollbar-backend/src/service/router.test.ts index f5dc2f1c3d..fb47e4ab7c 100644 --- a/plugins/rollbar-backend/src/service/router.test.ts +++ b/plugins/rollbar-backend/src/service/router.test.ts @@ -38,9 +38,7 @@ describe('createRouter', () => { const router = await createRouter({ rollbarApi, logger: getVoidLogger(), - config: ConfigReader.fromConfigs([ - { context: 'abc', data: { rollbar: { accountToken: 'foo' } } }, - ]), + config: new ConfigReader({ rollbar: { accountToken: 'foo' } }), }); app = express().use(router); }); @@ -106,7 +104,7 @@ describe('createRouter', () => { level: 50, occurrences: 100, projectId: 12345, - title: 'error occured', + title: 'error occurred', uniqueOccurrences: 10, }, counts: [10, 10, 10, 10, 10, 50], diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 89c7a273af..32995efab8 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,120 @@ # @backstage/plugin-rollbar +## 0.3.2 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.0 + +### Minor Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.5 ### 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 9cdd7895bf..3aaac0b078 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.2.5", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/rollbar" + }, + "keywords": [ + "backstage", + "rollbar" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", @@ -37,11 +47,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", diff --git a/plugins/rollbar/src/api/RollbarClient.ts b/plugins/rollbar/src/api/RollbarClient.ts index e612890655..d3ad3d63db 100644 --- a/plugins/rollbar/src/api/RollbarClient.ts +++ b/plugins/rollbar/src/api/RollbarClient.ts @@ -20,13 +20,18 @@ import { RollbarProject, RollbarTopActiveItem, } from './types'; -import { DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; export class RollbarClient implements RollbarApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; } async getAllProjects(): Promise { @@ -53,7 +58,10 @@ export class RollbarClient implements RollbarApi { private async get(path: string): Promise { const url = `${await this.discoveryApi.getBaseUrl('rollbar')}${path}`; - const response = await fetch(url); + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url, { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); if (!response.ok) { const payload = await response.text(); 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 ad3d4a554d..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 occured', - 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..dc0ffa259a 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -15,27 +15,39 @@ */ import { - createPlugin, createApiFactory, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, + identityApiRef, } 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({ api: rollbarApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new RollbarClient({ discoveryApi, identityApi }), }), ], - 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 1b22ee1f14..e7e3b0d718 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,383 @@ # @backstage/plugin-scaffolder-backend +## 0.8.0 + +### Minor Changes + +- a5f42cf66: # Stateless scaffolding + + The scaffolder has been redesigned to be horizontally scalable and to persistently store task state and execution logs in the database. + + Each scaffolder task is given a unique task ID which is persisted in the database. + Tasks are then picked up by a `TaskWorker` which performs the scaffolding steps. + Execution logs are also persisted in the database meaning you can now refresh the scaffolder task status page without losing information. + + The task status page is now dynamically created based on the step information stored in the database. + This allows for custom steps to be displayed once the next version of the scaffolder template schema is available. + + The task page is updated to display links to both the git repository and to the newly created catalog entity. + + Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffolder backend instead of the old `CatalogEntityClient`. + + Make sure to update `plugins/scaffolder.ts` + + ```diff + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, + - CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + + +import { CatalogClient } from '@backstage/catalog-client'; + + const discovery = SingleHostDiscovery.fromConfig(config); + -const entityClient = new CatalogEntityClient({ discovery }); + +const catalogClient = new CatalogClient({ discoveryApi: discovery }) + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + - entityClient, + database, + + catalogClient, + }); + ``` + + As well as adding the `@backstage/catalog-client` packages as a dependency of your backend package. + +### Patch Changes + +- Updated dependencies [bad21a085] +- Updated dependencies [a1f5e6545] + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.7.1 + +### Patch Changes + +- edbc27bfd: Added githubApp authentication to the scaffolder-backend plugin +- fb28da212: Switched to using `'x-access-token'` for authenticating Git over HTTPS towards GitHub. +- 0ada34a0f: Minor typo in migration +- 29c8bcc53: Fixed the `prepare` step for when using local templates that were added to the catalog using the `file:` target configuration. + No more `EPERM: operation not permitted` error messages. +- a341a8716: Fix parsing of the path to default to empty string not undefined if git-url-parse throws something we don't expect. Fixes the error `The "path" argument must be of type string.` when preparing. +- Updated dependencies [16fb1d03a] +- Updated dependencies [491f3a0ec] +- Updated dependencies [491f3a0ec] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] + - @backstage/backend-common@0.5.4 + - @backstage/integration@0.5.0 + +## 0.7.0 + +### Minor Changes + +- 615103a63: 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. + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- ffffea8e6: Minor updates to reflect the changes in `@backstage/integration` that made the fields `apiBaseUrl` and `apiUrl` mandatory. +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [72b96e880] +- Updated dependencies [5a5163519] + - @backstage/catalog-client@0.3.6 + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + +## 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` +- 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. + +## 0.3.5 + +### Patch Changes + +- 94c65a9d4: Added configuration schema for the commonly used properties +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.3.4 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.3.3 ### Patch Changes @@ -72,7 +450,7 @@ - 991a950e0: Added .fromConfig static factories for Preparers and Publishers + read integrations config to support url location types - c926765a2: Allow templates to be located on non-default branch - 6840a68df: Add authentication token to Scaffolder GitHub Preparer -- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub Enterprise instances - 5e4551e3a: Added support for configuring the working directory of the Scaffolder: ```yaml diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts new file mode 100644 index 0000000000..3b31b4f951 --- /dev/null +++ b/plugins/scaffolder-backend/config.d.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** Configuration options for the scaffolder plugin */ + 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; + api: { [key: string]: string }; + }; + 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..5bde7a7c68 --- /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([], 'task_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 f71d8bc36c..4d9d5f5404 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.3", + "version": "0.8.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/scaffolder-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,44 +29,54 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@gitbeaker/core": "^25.2.0", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", - "@types/dockerode": "^2.5.32", + "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/integration": "^0.5.0", + "@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", "cors": "^2.8.5", - "dockerode": "^3.2.0", + "cross-fetch": "^3.0.6", + "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.0", + "git-url-parse": "^11.4.4", "globby": "^11.0.0", + "handlebars": "^4.7.6", "helmet": "^4.0.0", + "isomorphic-git": "^1.8.0", "jsonschema": "^1.2.6", + "knex": "^0.21.6", + "luxon": "^1.26.0", "morgan": "^1.10.0", - "nodegit": "0.27.0", "uuid": "^8.2.0", "winston": "^3.2.1", - "yaml": "^1.10.0", - "cross-fetch": "^3.0.6" + "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@octokit/types": "^5.4.1", + "@backstage/cli": "^0.6.2", + "@backstage/test-utils": "^0.1.8", "@types/fs-extra": "^9.0.1", - "@types/git-url-parse": "^9.0.0", - "@types/nodegit": "0.26.11", + "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", + "mock-fs": "^4.13.0", + "msw": "^0.21.2", "supertest": "^4.0.2", "yaml": "^1.10.0" }, "files": [ - "dist" - ] + "dist", + "migrations", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml index 6866543ab0..a5bcd864b1 100644 --- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -4,10 +4,6 @@ metadata: name: example-templates description: A collection of all Backstage example templates spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml - - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml - - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml - - https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml - - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + - ./local-templates.yaml + - ./remote-templates.yaml diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 10109dee3d..91f887ed0a 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -18,6 +18,7 @@ spec: required: - component_id - use_typescript + - description properties: component_id: title: Name @@ -29,7 +30,13 @@ spec: type: string description: Help others understand what this website is for. use_typescript: - title: Use Typescript + title: Use TypeScript type: boolean - description: Include typescript + description: Include TypeScript default: true + use_github_actions: + title: Use Github Actions workflows to build this component + type: boolean + description: Use Github Actions + default: true + diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml index 36404f088c..168daffd20 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -22,6 +22,7 @@ spec: component_id: title: Name type: string + pattern: ^[a-z0-9A-Z_.-]{1,63}$ description: Unique name of the component description: title: Description 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/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml new file mode 100644 index 0000000000..468fb72ece --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/local-templates.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-templates-local + description: A collection of locally available Backstage example templates +spec: + targets: + - ./create-react-app/template.yaml + - ./docs-template/template.yaml + - ./react-ssr-template/template.yaml + - ./springboot-grpc-template/template.yaml 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/remote-templates.yaml b/plugins/scaffolder-backend/sample-templates/remote-templates.yaml new file mode 100644 index 0000000000..7846a0994e --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/remote-templates.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-templates-remote + description: A collection of remotely available Backstage example templates +spec: + type: url + targets: + - https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml 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/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 4541f03a4a..46a1433be2 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -14,48 +14,37 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { - ConflictError, - NotFoundError, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; + TemplateEntityV1alpha1, + TemplateEntityV1beta2, +} from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/catalog-client'; +import { ConflictError, NotFoundError } from '@backstage/backend-common'; /** * A catalog client tailored for reading out entity data from the catalog. */ export class CatalogEntityClient { - private readonly discovery: PluginEndpointDiscovery; - - constructor(options: { discovery: PluginEndpointDiscovery }) { - this.discovery = options.discovery; - } + constructor(private readonly catalogClient: CatalogApi) {} /** * Looks up a single template using a template name. * * Throws a NotFoundError or ConflictError if 0 or multiple templates are found. */ - async findTemplate(templateName: string): Promise { - const conditions = [ - 'kind=template', - `metadata.name=${encodeURIComponent(templateName)}`, - ]; - - const baseUrl = await this.discovery.getBaseUrl('catalog'); - const response = await fetch( - `${baseUrl}/entities?filter=${conditions.join(',')}`, - ); - - if (!response.ok) { - const text = await response.text(); - throw new Error( - `Request failed with ${response.status} ${response.statusText}, ${text}`, - ); - } - - const templates: TemplateEntityV1alpha1[] = await response.json(); + async findTemplate( + templateName: string, + options?: { token?: string }, + ): Promise { + const { items: templates } = (await this.catalogClient.getEntities( + { + filter: { + kind: 'template', + 'metadata.name': templateName, + }, + }, + options, + )) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] }; if (templates.length !== 1) { if (templates.length > 1) { 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 f1dbb24dd7..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, @@ -117,10 +163,10 @@ export class JobProcessor implements Processor { // Complete the current stage stage.status = 'COMPLETED'; } catch (error) { - // Log to the current stage the error that occured and fail the stage. + // 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 e54e796b3b..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,38 +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'); - }); - - 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..efba57a063 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts @@ -0,0 +1,133 @@ +/* + * 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'; +import { CatalogApi } from '@backstage/catalog-client'; +import { getEntityName } from '@backstage/catalog-model'; + +type Options = { + dockerClient: Docker; + preparers: PreparerBuilder; + templaters: TemplaterBuilder; + publishers: PublisherBuilder; + catalogClient: CatalogApi; +}; + +export function registerLegacyActions( + registry: TemplateActionRegistry, + options: Options, +) { + const { + dockerClient, + preparers, + templaters, + publishers, + catalogClient, + } = options; + + registry.register({ + id: 'legacy:prepare', + async handler(ctx) { + ctx.logger.info('Preparing the skeleton'); + const { protocol, url } = ctx.parameters; + const preparer = + protocol === 'file' ? new FilePreparer() : preparers.get(url as string); + + await preparer.prepare({ + url: url as string, + logger: ctx.logger, + workspacePath: ctx.workspacePath, + }); + }, + }); + + registry.register({ + id: 'legacy:template', + async handler(ctx) { + ctx.logger.info('Running the templater'); + const templater = templaters.get(ctx.parameters.templater as string); + 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); + } + }, + }); + + registry.register({ + id: 'catalog:register', + async handler(ctx) { + const { catalogInfoUrl } = ctx.parameters; + ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); + + const result = await catalogClient.addLocation({ + type: 'url', + target: catalogInfoUrl as string, + }); + if (result.entities.length >= 1) { + const { kind, name, namespace } = getEntityName(result.entities[0]); + ctx.output('entityRef', `${kind}:${namespace}/${name}`); + } + }, + }); +} 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 db3a110384..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,146 +14,103 @@ * limitations under the License. */ -const mocks = { - Clone: { clone: jest.fn() }, - CheckoutOptions: jest.fn(() => {}), -}; -jest.doMock('nodegit', () => mocks); -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 } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; +import { getVoidLogger, Git } from '@backstage/backend-common'; + +jest.mock('fs-extra'); describe('AzurePreparer', () => { - 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 mockGitClient = { + clone: jest.fn(), + }; + + const logger = getVoidLogger(); + + jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); + + const preparer = AzurePreparer.fromConfig({ + host: 'dev.azure.com', + token: 'fake-azure-token', + }); + + 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({ + 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(ConfigReader.fromConfigs([])); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - expect.any(String), - {}, - ); + await preparer.prepare(prepareOptions); + + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + dir: checkoutPath, + }); }); - it('calls the clone command with the correct arguments if an access token is provided for a repository', async () => { - const preparer = new AzurePreparer( - ConfigReader.fromConfigs([ - { - context: '', - data: { - scaffolder: { - azure: { - api: { - token: 'fake-token', - }, - }, - }, - }, - }, - ]), - ); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - expect.any(String), - { - fetchOpts: { - callbacks: { - credentials: expect.anything(), - }, - }, - }, - ); + 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(ConfigReader.fromConfigs([])); - delete mockEntity.spec.path; - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - expect.any(String), - {}, - ); - }); - - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath, + logger, }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + dir: checkoutPath, + }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); }); - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', + 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( - /\/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 333bf9116c..9dc29be890 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -13,64 +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 } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; -import { Clone, Cred } from 'nodegit'; -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(); + constructor(private readonly config: { token?: string }) {} - if (!['azure/api', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); + 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.config.token + ? Git.fromAuth({ + password: this.config.token, + username: 'notempty', + logger, + }) + : Git.fromAuth({ logger }); + + await git.clone({ + url: parsedGitUrl.toString('https'), + ref: parsedGitUrl.ref, + dir: checkoutPath, + }); + + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally } - 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 ?? '.', - ); - - const options = this.privateToken - ? { - fetchOpts: { - callbacks: { - credentials: () => - // Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - Cred.userpassPlaintextNew('notempty', this.privateToken), - }, - }, - } - : {}; - - await Clone.clone(repositoryCheckoutUrl, tempDir, options); - - return path.resolve(tempDir, templateDirectory); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts new file mode 100644 index 0000000000..faf7f0f6ff --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { BitbucketPreparer } from './bitbucket'; +import { getVoidLogger, Git } from '@backstage/backend-common'; +import path from 'path'; +import os from 'os'; + +jest.mock('fs-extra'); + +describe('BitbucketPreparer', () => { + const logger = getVoidLogger(); + const mockGitClient = { + clone: jest.fn(), + }; + + jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 () => { + await preparer.prepare(prepareOptions); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://bitbucket.org/backstage-project/backstage-repo', + 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 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 () => { + await preparer.prepare(prepareOptions); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://bitbucket.org/backstage-project/backstage-repo', + dir: checkoutPath, + ref: expect.any(String), + }); + }); + + 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(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, '1', '2', '3'), + templatePath, + ); + }); + + 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(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 new file mode 100644 index 0000000000..02865f8529 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import fs from 'fs-extra'; +import path from 'path'; +import { Git } from '@backstage/backend-common'; +import { PreparerBase, PreparerOptions } from './types'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; + +export class BitbucketPreparer implements PreparerBase { + static fromConfig(config: BitbucketIntegrationConfig) { + return new BitbucketPreparer({ + username: config.username, + token: config.token, + appPassword: config.appPassword, + }); + } + + constructor( + private readonly config: { + username?: string; + token?: string; + appPassword?: string; + }, + ) {} + + 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 git = Git.fromAuth({ logger, ...this.getAuth() }); + + await git.clone({ + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, + }); + + 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..f6e5600a83 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 targetPath = path.resolve(workspacePath, 'template'); - 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'), + targetPath, + { + recursive: true, + }, + ); + expect(fs.ensureDir).toHaveBeenCalledWith(targetPath); - 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..687cc39452 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 targetDir = path.join(workspacePath, 'template'); + await fs.ensureDir(targetDir); - 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, targetDir, { 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 86a870330e..976370bd21 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -14,133 +14,84 @@ * limitations under the License. */ -const mocks = { - Clone: { clone: jest.fn() }, - CheckoutOptions: jest.fn(() => {}), -}; -jest.doMock('nodegit', () => mocks); -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 } from '@backstage/backend-common'; +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() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://github.com/benjdlambert/backstage-graphql-template', - expect.any(String), - { - checkoutBranch: 'master', - }, + 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: 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() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://github.com/benjdlambert/backstage-graphql-template', - expect.any(String), - { - checkoutBranch: 'master', - }, - ); - }); - - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new GithubPreparer(); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + 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$/, - ); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://github.com/benjdlambert/backstage-graphql-template', + dir: checkoutPath, + ref: 'master', + }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); - 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', + 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( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); - }); - - it('calls the clone command with the token when provided', async () => { - const preparer = new GithubPreparer({ token: 'abc' }); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://github.com/benjdlambert/backstage-graphql-template', - expect.any(String), - { - checkoutBranch: 'master', - fetchOpts: { - callbacks: { - credentials: expect.any(Function), - }, - }, - }, - ); + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'x-access-token', + password: 'fake-token', + }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index e6b41907be..81ccdeb026 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -13,68 +13,59 @@ * 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 } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; -import { Clone, CloneOptions, Cred } from 'nodegit'; +import parseGitUrl from 'git-url-parse'; +import { + GitHubIntegrationConfig, + GithubCredentialsProvider, +} from '@backstage/integration'; export class GithubPreparer implements PreparerBase { - token?: string; - - constructor(params: { token?: string } = {}) { - this.token = params.token; + static fromConfig(config: GitHubIntegrationConfig) { + const credentialsProvider = GithubCredentialsProvider.create(config); + return new GithubPreparer({ credentialsProvider }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { token } = this; + constructor( + private readonly config: { credentialsProvider: GithubCredentialsProvider }, + ) {} - 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 { token } = await this.config.credentialsProvider.getCredentials({ + url, + }); - let cloneOptions: CloneOptions = { - checkoutBranch: parsedGitLocation.ref, - }; + const git = token + ? Git.fromAuth({ + username: 'x-access-token', + password: token, + logger, + }) + : Git.fromAuth({ logger }); - if (token) { - cloneOptions = { - ...cloneOptions, - fetchOpts: { - callbacks: { - credentials() { - return Cred.userpassPlaintextNew(token, 'x-oauth-basic'); - }, - }, - }, - }; + await git.clone({ + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, + }); + + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally } - - await Clone.clone(repositoryCheckoutUrl, tempDir, cloneOptions); - - return path.resolve(tempDir, templateDirectory); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index a86a51e0dc..2de2326505 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -13,152 +13,70 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -const mocks = { - Clone: { clone: jest.fn() }, - CheckoutOptions: jest.fn(() => {}), -}; -jest.doMock('nodegit', () => mocks); -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 } from '@backstage/backend-common'; +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 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', - }, - }, - }, - }, -}); +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: '', + token: 'fake-token', + apiBaseUrl: '', + baseUrl: '', + }); - ['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(ConfigReader.fromConfigs([])); - mockEntity = mockEntityWithProtocol(protocol); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://gitlab.com/benjdlambert/backstage-graphql-template', - 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 for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer( - ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { - processors: { - gitlabApi: { - privateToken: 'fake-token', - }, - }, - }, - }, - }, - ]), - ); - mockEntity = mockEntityWithProtocol(protocol); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://gitlab.com/benjdlambert/backstage-graphql-template', - expect.any(String), - { - fetchOpts: { - callbacks: { - credentials: expect.anything(), - }, - }, - }, - ); + 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 for a repository when no path is provided using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); - mockEntity = mockEntityWithProtocol(protocol); - delete mockEntity.spec.path; - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mocks.Clone.clone).toHaveBeenNthCalledWith( - 1, - 'https://gitlab.com/benjdlambert/backstage-graphql-template', - expect.any(String), - {}, - ); + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'oauth2', + password: 'fake-token', }); - it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); - mockEntity = mockEntityWithProtocol(protocol); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); + }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + 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 605c8db2df..e15de33ac6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -13,64 +13,50 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; +import { Git } from '@backstage/backend-common'; +import { GitLabIntegrationConfig } from '@backstage/integration'; import fs from 'fs-extra'; +import parseGitUrl from 'git-url-parse'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; -import { InputError } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; -import { Clone, Cred } from 'nodegit'; -import { Config } from '@backstage/config'; export class GitlabPreparer implements PreparerBase { - private readonly privateToken: string; - - constructor(config: Config) { - this.privateToken = - config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? - ''; + static fromConfig(config: GitLabIntegrationConfig) { + return new GitlabPreparer({ token: config.token }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - 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'`, - ); + 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 git = this.config.token + ? Git.fromAuth({ + password: this.config.token, + username: 'oauth2', + logger, + }) + : Git.fromAuth({ logger }); + + await git.clone({ + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, + }); + + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally } - 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 ?? '.', - ); - - const options = this.privateToken - ? { - fetchOpts: { - callbacks: { - credentials: () => - Cred.userpassPlaintextNew('oauth2', this.privateToken), - }, - }, - } - : {}; - - await Clone.clone(repositoryCheckoutUrl, tempDir, options); - - return path.resolve(tempDir, templateDirectory); } } 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 226f7e3836..a0b477b66a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -15,82 +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; - } - throw new Error(`No preparer integration found for url "${location}"`); - } - 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); + 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); - - 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 9ea7d2de5f..1ce47e04dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -13,62 +13,77 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +jest.mock('./helpers'); -jest.mock('nodegit'); -jest.mock('azure-devops-node-api/GitApi'); -jest.mock('azure-devops-node-api/interfaces/GitInterfaces'); -jest.mock('./helpers', () => ({ - pushToRemoteUserPass: jest.fn(), +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 { pushToRemoteUserPass } from './helpers'; - -const { mockGitApi } = require('azure-devops-node-api/GitApi') as { - mockGitApi: { - createRepository: jest.MockedFunction; - }; -}; +import { WebApi } from 'azure-devops-node-api'; +import * as helpers from './helpers'; +import { getVoidLogger } from '@backstage/backend-common'; 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(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://dev.azure.com/organization/project/_git/repo', - 'notempty', - 'fake-token', - ); + expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + 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 1e962bf223..4190ba8abb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -15,39 +15,68 @@ */ 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 { pushToRemoteUserPass } from './helpers'; -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); - await pushToRemoteUserPass(directory, remoteUrl, 'notempty', this.token); + 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: path.join(workspacePath, 'result'), + remoteUrl, + auth: { + username: 'notempty', + password: this.config.token, + }, + logger, + }); + 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 new file mode 100644 index 0000000000..4cf5e83ba3 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('./helpers'); + +import os from 'os'; +import { resolve } from 'path'; +import { BitbucketPublisher } from './bitbucket'; +import { initRepoAndPush } from './helpers'; +import { getVoidLogger } from '@backstage/backend-common'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; + +describe('Bitbucket Publisher', () => { + const logger = getVoidLogger(); + const server = setupServer(); + msw.setupDefaultHandlers(server); + + beforeEach(() => { + 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( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/project/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/project/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/project/repo', + }, + ], + }, + }), + ), + ), + ); + + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-token', + }, + { repoVisibility: 'private' }, + ); + + const result = await publisher.publish({ + values: { + storePath: 'https://bitbucket.org/project/repo', + owner: 'bob', + }, + workspacePath, + logger: logger, + }); + + expect(result).toEqual({ + remoteUrl: 'https://bitbucket.org/project/repo', + catalogInfoUrl: + 'https://bitbucket.org/project/repo/src/master/catalog-info.yaml', + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + 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( + rest.post( + 'https://bitbucket.mycompany.com/rest/api/1.0/projects/project/repos', + (_, res, ctx) => + res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: + 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ), + ), + ); + + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }, + { repoVisibility: 'private' }, + ); + + const result = await publisher.publish({ + values: { + storePath: 'https://bitbucket.mycompany.com/project/repo', + owner: 'bob', + }, + workspacePath, + logger: logger, + }); + + expect(result).toEqual({ + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + catalogInfoUrl: + 'https://bitbucket.mycompany.com/projects/project/repos/repo/catalog-info.yaml', + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + 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 new file mode 100644 index 0000000000..102a3a7d02 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; +import { initRepoAndPush } from './helpers'; +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 { + 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, + workspacePath, + logger, + }: PublisherOptions): Promise { + const { owner: project, name } = parseGitUrl(values.storePath); + + const description = values.description as string; + const result = await this.createRemote({ + project, + name, + description, + }); + + await initRepoAndPush({ + dir: path.join(workspacePath, 'result'), + remoteUrl: result.remoteUrl, + auth: { + 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(opts: { + project: string; + name: string; + description: string; + }): Promise { + if (this.config.host === 'bitbucket.org') { + return this.createBitbucketCloudRepository(opts); + } + return this.createBitbucketServerRepository(opts); + } + + private async createBitbucketCloudRepository(opts: { + project: string; + name: string; + description: string; + }): Promise { + const { project, name, description } = opts; + + let response: Response; + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + scm: 'git', + description: description, + is_private: this.config.repoVisibility === 'private', + }), + headers: { + Authorization: this.getAuthorizationHeader(), + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${project}/${name}`, + options, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + if (response.status === 200) { + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'https') { + remoteUrl = link.href; + } + } + + // TODO use the urlReader to get the default branch + const catalogInfoUrl = `${r.links.html.href}/src/master/catalog-info.yaml`; + return { remoteUrl, catalogInfoUrl }; + } + throw new Error(`Not a valid response code ${await response.text()}`); + } + + private getAuthorizationHeader(): string { + if (this.config.username && this.config.appPassword) { + const buffer = Buffer.from( + `${this.config.username}:${this.config.appPassword}`, + 'utf8', + ); + + return `Basic ${buffer.toString('base64')}`; + } + + if (this.config.token) { + return `Bearer ${this.config.token}`; + } + + throw new Error( + `Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`, + ); + } + + 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: description, + is_private: this.config.repoVisibility === 'private', + }), + headers: { + Authorization: this.getAuthorizationHeader(), + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `https://${this.config.host}/rest/api/1.0/projects/${project}/repos`, + options, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + if (response.status === 201) { + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'http') { + remoteUrl = link.href; + } + } + const catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`; + return { remoteUrl, catalogInfoUrl }; + } + throw new Error(`Not a valid response code ${await response.text()}`); + } +} 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 cda64faf25..1dc53e5a5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -15,19 +15,15 @@ */ jest.mock('@octokit/rest'); -jest.mock('nodegit'); -jest.mock('./helpers', () => ({ - pushToRemoteUserPass: jest.fn(), -})); +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 { pushToRemoteUserPass } from './helpers'; +import { initRepoAndPush } from './helpers'; const { mockGithubClient } = require('@octokit/rest') as { mockGithubClient: { @@ -38,37 +34,44 @@ const { mockGithubClient } = require('@octokit/rest') as { }; describe('GitHub Publisher', () => { + const logger = getVoidLogger(); beforeEach(() => { 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, }); expect(result).toEqual({ @@ -91,33 +94,42 @@ describe('GitHub Publisher', () => { repo: 'test', permission: 'admin', }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://github.com/backstage/backstage.git', - 'abc', - 'x-oauth-basic', - ); + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://github.com/backstage/backstage.git', + auth: { username: 'x-access-token', password: 'fake-token' }, + 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, }); expect(result).toEqual({ @@ -132,35 +144,45 @@ describe('GitHub Publisher', () => { private: false, }); expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://github.com/backstage/backstage.git', - 'abc', - 'x-oauth-basic', - ); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://github.com/backstage/backstage.git', + auth: { username: 'x-access-token', password: 'fake-token' }, + 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, }); expect(result).toEqual({ @@ -181,41 +203,44 @@ describe('GitHub Publisher', () => { username: 'bob', permission: 'admin', }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://github.com/backstage/backstage.git', - 'abc', - 'x-oauth-basic', - ); + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://github.com/backstage/backstage.git', + auth: { username: 'x-access-token', password: 'fake-token' }, + 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, }); expect(result).toEqual({ @@ -229,40 +254,43 @@ describe('GitHub Publisher', () => { private: true, visibility: 'internal', }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://github.com/backstage/backstage.git', - 'abc', - 'x-oauth-basic', - ); + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://github.com/backstage/backstage.git', + auth: { username: 'x-access-token', password: 'fake-token' }, + 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, }); expect(result).toEqual({ @@ -276,12 +304,12 @@ describe('GitHub Publisher', () => { name: 'test', private: true, }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'https://github.com/backstage/backstage.git', - 'abc', - 'x-oauth-basic', - ); + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: resultPath, + remoteUrl: 'https://github.com/backstage/backstage.git', + auth: { username: 'x-access-token', password: 'fake-token' }, + logger, + }); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 64976b8e41..a449eddd5d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,82 +15,125 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; +import { initRepoAndPush } from './helpers'; +import { + GitHubIntegrationConfig, + GithubCredentialsProvider, +} from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; import { Octokit } from '@octokit/rest'; -import { pushToRemoteUserPass } from './helpers'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; +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 && !config.apps) { + return undefined; + } - constructor({ - client, - token, - repoVisibility = 'public', - }: GithubPublisherParams) { - this.client = client; - this.token = token; - this.repoVisibility = repoVisibility; + const credentialsProvider = GithubCredentialsProvider.create(config); + + return new GithubPublisher({ + credentialsProvider, + repoVisibility, + apiBaseUrl: config.apiBaseUrl, + }); } + constructor( + private readonly config: { + credentialsProvider: GithubCredentialsProvider; + repoVisibility: RepoVisibilityOptions; + apiBaseUrl: string | undefined; + }, + ) {} + async publish({ values, - directory, + workspacePath, + logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); - await pushToRemoteUserPass( - directory, + const { owner, name } = parseGitUrl(values.storePath); + + const { token } = await this.config.credentialsProvider.getCredentials({ + url: values.storePath, + }); + + if (!token) { + throw new Error( + `No token could be acquired for URL: ${values.storePath}`, + ); + } + + const client = new Octokit({ + auth: token, + baseUrl: this.config.apiBaseUrl, + }); + + const description = values.description as string; + const access = values.access as string; + const remoteUrl = await this.createRemote({ + client, + description, + access, + name, + owner, + }); + + await initRepoAndPush({ + dir: path.join(workspacePath, 'result'), remoteUrl, - this.token, - 'x-oauth-basic', - ); + auth: { + username: 'x-access-token', + password: token, + }, + logger, + }); + 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('/'); - const description = values.description as string; + private async createRemote(opts: { + client: Octokit; + access: string; + name: string; + owner: string; + description: string; + }) { + const { client, access, description, owner, name } = opts; - const user = await this.client.users.getByUsername({ username: owner }); + const user = await client.users.getByUsername({ + username: owner, + }); const repoCreationPromise = user.data.type === 'Organization' - ? this.client.repos.createInOrg({ + ? 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({ + : 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 client.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, team_slug: team, owner, @@ -99,7 +142,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 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 a87fa6c6d9..3826a54ddb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -14,34 +14,56 @@ * limitations under the License. */ -jest.mock('nodegit'); -jest.mock('@gitbeaker/node'); -jest.mock('./helpers', () => ({ - pushToRemoteUserPass: jest.fn(), +jest.mock('@gitbeaker/node', () => ({ + Gitlab: jest.fn(), })); -import { GitlabPublisher } from './gitlab'; -import { Gitlab as GitlabAPI } from '@gitbeaker/core'; -import { Gitlab } from '@gitbeaker/node'; -import { pushToRemoteUserPass } from './helpers'; +jest.mock('./helpers'); -const { mockGitlabClient } = require('@gitbeaker/node') as { - mockGitlabClient: { - Namespaces: jest.Mocked; - Projects: jest.Mocked; - Users: jest.Mocked; - }; -}; +import os from 'os'; +import path from 'path'; +import { GitlabPublisher } from './gitlab'; +import { Gitlab } from '@gitbeaker/node'; +import { initRepoAndPush } from './helpers'; +import { getVoidLogger } from '@backstage/backend-common'; describe('GitLab Publisher', () => { - const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); + const logger = getVoidLogger(); + 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', + apiBaseUrl: 'https://gitlab.com/api/v4', + token: 'fake-token', + baseUrl: 'https://gitlab.hosted.com', + }, + { repoVisibility: 'public' }, + ); + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, } as { id: number }); @@ -49,29 +71,48 @@ 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: resultPath, + remoteUrl: 'mockclone', + auth: { username: 'oauth2', password: 'fake-token' }, + logger, }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'mockclone', - 'oauth2', - 'fake-token', - ); }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + token: 'fake-token', + baseUrl: 'https://gitlab.com', + }, + { repoVisibility: 'public' }, + ); + mockGitlabClient.Namespaces.show.mockResolvedValue({}); mockGitlabClient.Users.current.mockResolvedValue({ id: 21, @@ -80,26 +121,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: { - 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: resultPath, + remoteUrl: 'mockclone', + auth: { username: 'oauth2', password: 'fake-token' }, + logger, }); - expect(pushToRemoteUserPass).toHaveBeenCalledWith( - '/tmp/test', - 'mockclone', - 'oauth2', - 'fake-token', - ); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index 4fae8e8a5c..4b10d653da 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -15,46 +15,90 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Gitlab } from '@gitbeaker/core'; -import { pushToRemoteUserPass } from './helpers'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; +import { Gitlab } from '@gitbeaker/node'; +import { Gitlab as GitlabClient } from '@gitbeaker/core'; +import { initRepoAndPush } from './helpers'; +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); - await pushToRemoteUserPass(directory, remoteUrl, 'oauth2', this.token); + const { owner, name } = parseGitUrl(values.storePath); - return { remoteUrl }; + const remoteUrl = await this.createRemote({ + owner, + name, + }); + + await initRepoAndPush({ + dir: path.join(workspacePath, 'result'), + remoteUrl, + auth: { + username: 'oauth2', + password: this.config.token, + }, + logger, + }); + + 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.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts deleted file mode 100644 index 3a317adacd..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts +++ /dev/null @@ -1,112 +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. - */ -jest.mock('nodegit'); -import * as NodeGit from 'nodegit'; -import { pushToRemoteCred } from './helpers'; - -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - -describe('pushToRemoteCred', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const directory = '/tmp/test/dir'; - const remote = 'mockclone'; - const credentialsProvider = () => - NodeGit.Cred.userpassPlaintextNew('username', 'password'); - - it('should call init on the repo with the directory', async () => { - await pushToRemoteCred(directory, remote, credentialsProvider); - - expect(Repository.init).toHaveBeenCalledWith(directory, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await pushToRemoteCred(directory, remote, credentialsProvider); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await pushToRemoteCred(directory, remote, credentialsProvider); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await pushToRemoteCred(directory, remote, credentialsProvider); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await pushToRemoteCred(directory, remote, credentialsProvider); - - expect(Remote.create).toHaveBeenCalledWith(mockRepo, 'origin', 'mockclone'); - }); - - it('shoud push to the remote repo', async () => { - await pushToRemoteCred(directory, remote, credentialsProvider); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( - 'username', - 'password', - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts index 51f5a4c854..ffefb88cf0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -13,43 +13,57 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Repository, Remote, Signature, Cred } from 'nodegit'; -export async function pushToRemoteCred( - directory: string, - remote: string, - credentialsProvider: () => Cred, -): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); +import globby from 'globby'; +import { Logger } from 'winston'; +import { Git } from '@backstage/backend-common'; - const remoteRepo = await Remote.create(repo, 'origin', remote); +export async function initRepoAndPush({ + dir, + remoteUrl, + auth, + logger, +}: { + dir: string; + remoteUrl: string; + auth: { username: string; password: string }; + logger: Logger; +}): Promise { + const git = Git.fromAuth({ + username: auth.username, + password: auth.password, + logger, + }); - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - credentials: credentialsProvider, - }, + await git.init({ + dir, + }); + + const paths = await globby(['./**', './**/.*', '!.git'], { + cwd: dir, + gitignore: true, + dot: true, + }); + + for (const filepath of paths) { + await git.add({ dir, filepath }); + } + + await git.commit({ + dir, + message: 'Initial commit', + author: { name: 'Scaffolder', email: 'scaffolder@backstage.io' }, + committer: { name: 'Scaffolder', email: 'scaffolder@backstage.io' }, + }); + + await git.addRemote({ + dir, + url: remoteUrl, + remote: 'origin', + }); + + await git.push({ + dir, + remote: 'origin', }); } - -export async function pushToRemoteUserPass( - directory: string, - remote: string, - username: string, - password: string, -): Promise { - return pushToRemoteCred(directory, remote, () => - Cred.userpassPlaintextNew(username, password), - ); -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/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 db383aaf7f..7f4fa6b450 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -14,142 +14,97 @@ * 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, + 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; - } - throw new Error(`No preparer integration found for url "${location}"`); - } - 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 }, + _options: { 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, + for (const integration of scm.azure.list()) { + const publisher = await AzurePublisher.fromConfig(integration.config); + if (publisher) { + publishers.register(integration.config.host, publisher); + } + } + + 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); + } + } + + 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); + } + } + + 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}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); - if (gitLabConfig) { - try { - const gitLabToken = gitLabConfig.getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } catch (e) { - const providerName = 'gitlab'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const azureConfig = config.getOptionalConfig('scaffolder.azure'); - if (azureConfig) { - try { - const baseUrl = azureConfig.getString('baseUrl'); - const azureToken = azureConfig.getConfig('api').getString('token'); - - const authHandler = getPersonalAccessTokenHandler(azureToken); - const webApi = new WebApi(baseUrl, authHandler); - const azureClient = await webApi.getGitApi(); - - const azurePublisher = new AzurePublisher(azureClient, azureToken); - publishers.register('azure/api', azurePublisher); - } catch (e) { - const providerName = 'azure'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); + if (publisher) { + publishers.register(integration.config.host, publisher); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index f3bc59e19d..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,8 @@ * 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'; /** * Publisher is in charge of taking a folder created by @@ -33,8 +31,9 @@ export type PublisherBase = { }; export type PublisherOptions = { - values: RequiredTemplateValues & Record; - directory: string; + values: TemplaterValues; + workspacePath: string; + logger: Logger; }; export type PublisherResult = { @@ -43,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.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts deleted file mode 100644 index 0fcd8a4f15..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts +++ /dev/null @@ -1,80 +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 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'; - -export class CreateReactAppTemplater implements TemplaterBase { - public async run(options: TemplaterRunOptions): Promise { - const { - component_id: componentName, - use_typescript: withTypescript, - description, - owner, - } = options.values; - - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); - - await runDockerContainer({ - imageName: 'node:lts-alpine', - args: [ - 'create-react-app', - componentName as string, - withTypescript ? ' --template typescript' : '', - ], - templateDir: options.directory, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, - createOptions: { - Entrypoint: ['npx'], - WorkingDir: '/result', - }, - }); - - // Need to also make a catalog-info.yaml to store the data about the service. - const componentInfo = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: componentName, - description, - }, - spec: { - type: 'website', - lifecycle: 'experimental', - owner, - }, - }; - - const finalDir = path.resolve( - resultDir, - options.values.component_id as string, - ); - - await fs.promises.writeFile( - `${finalDir}/catalog-info.yaml`, - yaml.stringify(componentInfo), - ); - - return { - resultDir: finalDir, - }; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts new file mode 100644 index 0000000000..5e957e0504 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { runDockerContainer } from '../helpers'; +import { TemplaterBase, TemplaterRunOptions } from '..'; +import path from 'path'; +import * as yaml from 'yaml'; + +// 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({ + workspacePath, + values, + logStream, + dockerClient, + }: TemplaterRunOptions): Promise { + const { + component_id: componentName, + use_typescript: withTypescript, + use_github_actions: withGithubActions, + description, + owner, + } = values; + const intermediateDir = path.join(workspacePath, 'template'); + await fs.ensureDir(intermediateDir); + + const resultDir = path.join(workspacePath, 'result'); + + await runDockerContainer({ + imageName: 'node:lts-alpine', + args: [ + 'create-react-app', + componentName as string, + withTypescript ? ' --template typescript' : '', + ], + templateDir: intermediateDir, + resultDir: intermediateDir, + logStream: logStream, + dockerClient: dockerClient, + createOptions: { + Entrypoint: ['npx'], + WorkingDir: '/result', + }, + }); + + // 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.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 + ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; + } + + const componentInfo = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: componentName, + description, + annotations: { + ...extraAnnotations, + }, + }, + spec: { + type: 'website', + lifecycle: 'experimental', + owner, + }, + }; + + await fs.writeFile( + `${resultDir}/catalog-info.yaml`, + yaml.stringify(componentInfo), + ); + } +} 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/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts deleted file mode 100644 index 938d906857..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export type RemoteProtocol = - | 'file' - | 'github' - | 'gitlab' - | 'gitlab/api' - | 'azure/api'; 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..d069008282 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -0,0 +1,276 @@ +/* + * 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'; +import { DateTime } from 'luxon'; + +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 getTask(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: Number(event.id), + taskId, + body, + type: event.event_type, + createdAt: + typeof event.created_at === 'string' + ? DateTime.fromSQL(event.created_at, { zone: 'UTC' }).toISO() + : 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..fd2de30332 --- /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({} as TaskSpec); + 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({} as TaskSpec); + 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({} as TaskSpec); + const task = await broker.claim(); + await task.complete('completed'); + const taskRow = await storage.getTask(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({} as TaskSpec); + const task = await broker.claim(); + await task.complete('failed'); + const taskRow = await storage.getTask(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({} as TaskSpec); + + 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({} as TaskSpec); + const task = await broker.claim(); + + const initialTask = await storage.getTask(taskId); + + for (;;) { + const maybeTask = await storage.getTask(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({} as TaskSpec); + 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.getTask(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..fb1f4ab422 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -0,0 +1,217 @@ +/* + * 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 { Logger } from 'winston'; +import { + CompletedTaskState, + Task, + TaskSpec, + TaskStore, + TaskBroker, + DispatchResult, + DbTaskEventRow, + DbTaskRow, +} 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, metadata?: JsonObject): Promise { + await this.storage.emitLogEvent({ + taskId: this.state.taskId, + body: { message, ...metadata }, + }); + } + + async complete( + result: CompletedTaskState, + metadata?: JsonObject, + ): Promise { + await this.storage.completeTask({ + taskId: this.state.taskId, + status: result === 'failed' ? 'failed' : 'completed', + eventBody: { + message: `Run completed with status: ${result}`, + ...metadata, + }, + }); + 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, + }; + } + + async get(taskId: string): Promise { + return this.storage.getTask(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..9d0c0863aa --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -0,0 +1,162 @@ +/* + * 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, JsonObject } from '@backstage/config'; +import { TaskBroker, Task } from './types'; +import fs from 'fs-extra'; +import path from 'path'; +import { TemplateActionRegistry } from './TemplateConverter'; +import * as handlebars from 'handlebars'; + +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 } = this.options; + + const workspacePath = path.join( + this.options.workingDirectory, + await task.getWorkspaceName(), + ); + await fs.ensureDir(workspacePath); + await task.emitLog( + `Starting up task with ${task.spec.steps.length} steps`, + ); + + const templateCtx: { + parameters: JsonObject; + steps: { + [stepName: string]: { output: { [outputName: string]: JsonValue } }; + }; + } = { parameters: task.spec.values, steps: {} }; + + for (const step of task.spec.steps) { + const metadata = { stepId: step.id }; + try { + 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', async data => { + const message = data.toString().trim(); + if (message?.length > 1) { + await task.emitLog(message, metadata); + } + }); + + taskLogger.add(new winston.transports.Stream({ stream })); + await task.emitLog(`Beginning step ${step.name}`, { + ...metadata, + status: 'processing', + }); + + const action = actionRegistry.get(step.action); + if (!action) { + throw new Error(`Action '${step.action}' does not exist`); + } + + const parameters = JSON.parse( + JSON.stringify(step.parameters), + (_key, value) => { + if (typeof value === 'string') { + return handlebars.compile(value, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + } + return value; + }, + ); + + const stepOutputs: { [name: string]: JsonValue } = {}; + + await action.handler({ + logger: taskLogger, + logStream: stream, + parameters, + workspacePath, + output(name: string, value: JsonValue) { + stepOutputs[name] = value; + }, + }); + + templateCtx.steps[step.id] = { output: stepOutputs }; + + await task.emitLog(`Finished step ${step.name}`, { + ...metadata, + status: 'completed', + }); + } catch (error) { + await task.emitLog(String(error.stack), { + ...metadata, + status: 'failed', + }); + throw error; + } + } + + const output = JSON.parse( + JSON.stringify(task.spec.output), + (_key, value) => { + if (typeof value === 'string') { + return handlebars.compile(value, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + } + return value; + }, + ); + + await task.complete('completed', { output }); + } catch (error) { + await task.complete('failed', { + error: { name: error.name, message: error.message }, + }); + } + } +} 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..7f76ac33d0 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -0,0 +1,134 @@ +/* + * 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, dirname } from 'path'; +import { JsonValue } from '@backstage/config'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { Logger } from 'winston'; +import { 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(dirname(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: 'Publish', + action: 'legacy:publish', + parameters: { + values, + }, + }); + + steps.push({ + id: 'register', + name: 'Register', + action: 'catalog:register', + parameters: { + catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', + }, + }); + + return { + values: {}, + steps, + output: { + remoteUrl: '{{ steps.publish.output.remoteUrl }}', + catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', + entityRef: '{{ steps.register.output.entityRef }}', + }, + }; +} + +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..27a1b62d04 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -0,0 +1,114 @@ +/* + * 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 = { + values: JsonObject; + steps: Array<{ + id: string; + name: string; + action: string; + parameters?: JsonObject; + }>; + output: { [name: string]: string }; +}; + +export type DispatchResult = { + taskId: string; +}; + +export interface Task { + spec: TaskSpec; + done: boolean; + emitLog(message: string, metadata?: JsonValue): Promise; + complete(result: CompletedTaskState, metadata?: JsonValue): 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 }>; + getTask(taskId: string): Promise; + 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 c2b60a2040..8fd53e5fb6 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,21 +24,42 @@ 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'; import { createRouter } from './router'; import { Templaters, Preparers, Publishers } from '../scaffolder'; import Docker from 'dockerode'; +import { CatalogApi } from '@backstage/catalog-client'; jest.mock('dockerode'); -const generateEntityClient: any = (template: any) => ({ - findTemplate: () => Promise.resolve(template), -}); +const createCatalogClient = (templates: any[] = []) => + ({ + getEntities: async () => ({ items: templates }), + } as CatalogApi); + +function createDatabase(): PluginDatabaseManager { + return SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('scaffolder'); +} describe('createRouter - working directory', () => { const mockPrepare = jest.fn(); @@ -47,7 +69,7 @@ describe('createRouter - working directory', () => { const mockPreparer = { prepare: mockPrepare, }; - mockPreparers.register('azure/api', mockPreparer); + mockPreparers.register('dev.azure.com', mockPreparer); }); beforeEach(() => { @@ -55,11 +77,8 @@ describe('createRouter - working directory', () => { }); const workDirConfig = (path: string) => ({ - context: '', - data: { - backend: { - workingDirectory: path, - }, + backend: { + workingDirectory: path, }, }); @@ -68,7 +87,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: { @@ -78,8 +97,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,9 +108,10 @@ describe('createRouter - working directory', () => { preparers: new Preparers(), templaters: new Templaters(), publishers: new Publishers(), - config: ConfigReader.fromConfigs([workDirConfig('/path')]), + config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), - entityClient: mockedEntityClient, + database: createDatabase(), + catalogClient: createCatalogClient([template]), }), ).rejects.toThrow('access error'); }); @@ -104,20 +122,26 @@ describe('createRouter - working directory', () => { preparers: mockPreparers, templaters: new Templaters(), publishers: new Publishers(), - config: ConfigReader.fromConfigs([workDirConfig('/path')]), + config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), - entityClient: mockedEntityClient, + database: createDatabase(), + catalogClient: createCatalogClient([template]), }); 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,19 +151,26 @@ describe('createRouter - working directory', () => { preparers: mockPreparers, templaters: new Templaters(), publishers: new Publishers(), - config: ConfigReader.fromConfigs([]), + config: new ConfigReader({}), dockerClient: new Docker(), - entityClient: mockedEntityClient, + database: createDatabase(), + catalogClient: createCatalogClient([template]), }); 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(), }); }); }); @@ -154,6 +185,9 @@ describe('createRouter', () => { name: 'create-react-app-template', tags: ['experimental', 'react', 'cra'], title: 'Create React App Template', + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, }, spec: { owner: 'web@example.com', @@ -172,8 +206,8 @@ describe('createRouter', () => { }, use_typescript: { default: true, - description: 'Include typescript', - title: 'Use Typescript', + description: 'Include TypeScript', + title: 'Use TypeScript', type: 'boolean', }, }, @@ -190,9 +224,10 @@ describe('createRouter', () => { preparers: new Preparers(), templaters: new Templaters(), publishers: new Publishers(), - config: ConfigReader.fromConfigs([]), + config: new ConfigReader({}), dockerClient: new Docker(), - entityClient: generateEntityClient(template), + database: createDatabase(), + catalogClient: createCatalogClient([template]), }); app = express().use(router); }); @@ -203,12 +238,48 @@ 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); }); }); + + describe('POST /v2/tasks', () => { + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateName: '', + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); + + expect(response.status).toEqual(400); + }); + + it('return the template id', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateName: 'create-react-app-template', + values: { + storePath: 'https://github.com/backstage/backstage', + component_id: '123', + name: 'test', + use_typescript: false, + }, + }); + + expect(response.body.id).toBeDefined(); + expect(response.status).toEqual(201); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ac3b1c80ec..1645cb928d 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,22 +14,47 @@ * 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, dirname } 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 { + InputError, + NotFoundError, + PluginDatabaseManager, +} from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import { + TemplateEntityV1alpha1, + TemplateEntityV1beta2, + Entity, +} from '@backstage/catalog-model'; export interface RouterOptions { preparers: PreparerBuilder; @@ -39,7 +64,23 @@ export interface RouterOptions { logger: Logger; config: Config; dockerClient: Docker; - entityClient: CatalogEntityClient; + database: PluginDatabaseManager; + catalogClient: CatalogApi; +} + +function isAlpha1Template( + entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, +): entity is TemplateEntityV1alpha1 { + return ( + entity.apiVersion === 'backstage.io/v1alpha1' || + entity.apiVersion === 'backstage.io/v1beta1' + ); +} + +function isBeta2Template( + entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, +): entity is TemplateEntityV1beta2 { + return entity.apiVersion === 'backstage.io/v1beta2'; } export async function createRouter( @@ -55,42 +96,47 @@ export async function createRouter( logger: parentLogger, config, dockerClient, - entityClient, + database, + catalogClient, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); - const jobProcessor = new JobProcessor(); + const workingDirectory = await getWorkingDirectory(config, logger); + const jobProcessor = await JobProcessor.fromConfig({ config, logger }); + const entityClient = new CatalogEntityClient(catalogClient); - 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, + catalogClient, + }); + + worker.start(); router .get('/v1/job/:jobId', ({ params }, res) => { const job = jobProcessor.get(params.jobId); if (!job) { - res.status(404).send({ error: 'job not found' }); + res.status(404).json({ error: 'job not found' }); return; } - res.send({ + res.json({ id: job.id, metadata: { ...job.context, @@ -107,10 +153,22 @@ 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); + // Forward authorization from client + const template = await entityClient.findTemplate(templateName, { + token: getBearerToken(req.headers.authorization), + }); + if (!isAlpha1Template(template)) { + throw new InputError( + `This endpoint does not support templates with version ${template.apiVersion}`, + ); + } const validationResult: ValidatorResult = validate( values, @@ -128,37 +186,63 @@ 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( + dirname(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; }, @@ -166,9 +250,184 @@ 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 + .get( + '/v2/templates/:namespace/:kind/:name/parameter-schema', + async (req, res) => { + const { namespace, kind, name } = req.params; + + if (namespace !== 'default') { + throw new InputError( + `Invalid namespace, only 'default' namespace is supported`, + ); + } + if (kind.toLowerCase() !== 'template') { + throw new InputError( + `Invalid kind, only 'Template' kind is supported`, + ); + } + + const template = await entityClient.findTemplate(name); + if (isBeta2Template(template)) { + const parameters = [template.spec.parameters ?? []].flat(); + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: parameters.map(schema => ({ + title: schema.title ?? 'Fill in template parameters', + schema, + })), + }); + } else if (isAlpha1Template(template)) { + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: [ + { + title: 'Fill in template parameters', + schema: template.spec.schema, + }, + { + title: 'Choose owner and repo', + schema: { + type: 'object', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: + 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', + }, + access: { + type: 'string', + title: 'Access', + description: + 'Who should have access, in org/team or user format', + }, + }, + }, + }, + ], + }); + } else { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, + ); + } + }, + ) + .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); + + let taskSpec; + if (isAlpha1Template(template)) { + const result = validate(values, template.spec.schema); + + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + + taskSpec = templateEntityToSpec(template, values); + } else if (isBeta2Template(template)) { + for (const parameters of [template.spec.parameters ?? []].flat()) { + const result = validate(values, parameters); + + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + } + + taskSpec = { + values, + steps: template.spec.steps.map((step, index) => ({ + ...step, + id: step.id ?? `step-${index + 1}`, + name: step.name ?? step.action, + })), + output: template.spec.output ?? {}, + }; + } else { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, + ); + } + + const result = await taskBroker.dispatch(taskSpec); + + res.status(201).json({ id: result.taskId }); + }) + .get('/v2/tasks/:taskId', async (req, res) => { + const { taskId } = req.params; + const task = await taskBroker.get(taskId); + if (!task) { + throw new NotFoundError(`Task with id ${taskId} does not exist`); + } + res.status(200).json(task); + }) + .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(); @@ -177,3 +436,7 @@ export async function createRouter( return app; } + +function getBearerToken(header?: string): string | undefined { + return header?.match(/Bearer\s+(\S+)/i)?.[1]; +} diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 197d54a9a4..5d5523f821 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,290 @@ # @backstage/plugin-scaffolder +## 0.6.0 + +### Minor Changes + +- a5f42cf66: The Scaffolder and Catalog plugins have been migrated to partially require use of the [new composability API](https://backstage.io/docs/plugins/composability). The Scaffolder used to register its pages using the deprecated route registration plugin API, but those registrations have been removed. This means you now need to add the Scaffolder plugin page to the app directly. + + The page is imported from the Scaffolder plugin and added to the `` component: + + ```tsx + } /> + ``` + + The Catalog plugin has also been migrated to use an [external route reference](https://backstage.io/docs/plugins/composability#binding-external-routes-in-the-app) to dynamically link to the create component page. This means you need to migrate the catalog plugin to use the new extension components, as well as bind the external route. + + To use the new extension components, replace existing usage of the `CatalogRouter` with the following: + + ```tsx + } /> + }> + + + ``` + + And to bind the external route from the catalog plugin to the scaffolder template index page, make sure you have the appropriate imports and add the following to the `createApp` call: + + ```ts + import { catalogPlugin } from '@backstage/plugin-catalog'; + import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + + const app = createApp({ + // ... + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + }, + }); + ``` + +- d0760ecdf: Moved common useStarredEntities hook to plugin-catalog-react +- e8e35fb5f: Adding Search and Filter features to Scaffolder/Templates Grid + +### Patch Changes + +- a5f42cf66: # Stateless scaffolding + + The scaffolder has been redesigned to be horizontally scalable and to persistently store task state and execution logs in the database. + + Each scaffolder task is given a unique task ID which is persisted in the database. + Tasks are then picked up by a `TaskWorker` which performs the scaffolding steps. + Execution logs are also persisted in the database meaning you can now refresh the scaffolder task status page without losing information. + + The task status page is now dynamically created based on the step information stored in the database. + This allows for custom steps to be displayed once the next version of the scaffolder template schema is available. + + The task page is updated to display links to both the git repository and to the newly created catalog entity. + + Component registration has moved from the frontend into a separate registration step executed by the `TaskWorker`. This requires that a `CatalogClient` is passed to the scaffolder backend instead of the old `CatalogEntityClient`. + + Make sure to update `plugins/scaffolder.ts` + + ```diff + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, + - CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + + +import { CatalogClient } from '@backstage/catalog-client'; + + const discovery = SingleHostDiscovery.fromConfig(config); + -const entityClient = new CatalogEntityClient({ discovery }); + +const catalogClient = new CatalogClient({ discoveryApi: discovery }) + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + - entityClient, + database, + + catalogClient, + }); + ``` + + As well as adding the `@backstage/catalog-client` packages as a dependency of your backend package. + +- e488f0502: Update messages that process during loading, error, and no templates found. + Remove unused dependencies. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [a1f5e6545] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + - @backstage/config@0.1.3 + +## 0.5.1 + +### Patch Changes + +- 6c4a76c59: Make the `TemplateCard` conform to what material-ui recommends in their examples. This fixes the extra padding around the buttons. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.5.0 + +### Minor Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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` +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/core@0.4.2 + - @backstage/plugin-catalog@0.2.8 + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.3.2 ### Patch Changes @@ -99,7 +384,7 @@ ![failed-to-create-component](https://user-images.githubusercontent.com/33940798/94339296-90969400-0016-11eb-9a74-ce16b3dd8d88.gif) - c5ef12926: fix the accordion details design when job stage fail -- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub Enterprise instances - Updated dependencies [28edd7d29] - Updated dependencies [819a70229] - Updated dependencies [3a4236570] diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 812a5585d4..1501a1cd5e 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -14,7 +14,29 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { discoveryApiRef, identityApiRef } from '@backstage/core'; +import { CatalogClient } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { ScaffolderPage } from '../src/plugin'; +import { ScaffolderClient, 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, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new ScaffolderClient({ discoveryApi, identityApi }), + }) + .addPage({ + path: '/create', + title: 'Create', + element: , + }) + .render(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 381f2bfdbf..6e26d056cc 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.3.2", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/scaffolder" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,32 +30,40 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", - "moment": "^2.26.0", + "git-url-parse": "^11.4.4", + "humanize-duration": "^3.25.1", + "luxon": "^1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.3.0" + "swr": "^0.3.0", + "use-immer": "^0.4.2", + "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", + "@types/humanize-duration": "^3.18.1", + "@testing-library/react-hooks": "^3.3.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "cross-fetch": "^3.0.6", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ca52418e92..ac165e021a 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,18 +14,104 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { EntityName } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; +import { + createApiRef, + DiscoveryApi, + Observable, + IdentityApi, +} from '@backstage/core'; +import ObservableImpl from 'zen-observable'; +import { ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', description: 'Used to make requests towards the scaffolder backend', }); -export class ScaffolderApi { - private readonly discoveryApi: DiscoveryApi; +type TemplateParameterSchema = { + title: string; + steps: Array<{ + title: string; + schema: JsonObject; + }>; +}; - constructor(options: { discoveryApi: DiscoveryApi }) { +export type LogEvent = { + type: 'log' | 'completion'; + body: { + message: string; + stepId?: string; + status?: Status; + }; + createdAt: string; + id: string; + taskId: string; +}; + +export interface ScaffolderApi { + getTemplateParameterSchema( + templateName: EntityName, + ): Promise; + + /** + * Executes the scaffolding of a component, given a template and its + * parameter values. + * + * @param templateName Name of the Template entity for the scaffolder to use. New project is going to be created out of this template. + * @param values Parameters for the template, e.g. name, description + */ + scaffold(templateName: string, values: Record): Promise; + + getTask(taskId: string): Promise; + + streamLogs({ + taskId, + after, + }: { + taskId: string; + after?: number; + }): Observable; +} +export class ScaffolderClient implements ScaffolderApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + + async getTemplateParameterSchema( + templateName: EntityName, + ): Promise { + const { namespace, kind, name } = templateName; + + const token = await this.identityApi.getIdToken(); + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const templatePath = [namespace, kind, name] + .map(s => encodeURIComponent(s)) + .join('/'); + const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`; + + const response = await fetch(url, { + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch template parameter schema, ${await response.text()}`, + ); + } + + const schema: TemplateParameterSchema = await response.json(); + return schema; } /** @@ -35,12 +121,17 @@ export class ScaffolderApi { * @param templateName Template name for the scaffolder to use. New project is going to be created out of this template. * @param values Parameters for the template, e.g. name, description */ - async scaffold(templateName: string, values: Record) { - const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; + async scaffold( + templateName: string, + values: Record, + ): Promise { + const token = await this.identityApi.getIdToken(); + const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), }, body: JSON.stringify({ templateName, values: { ...values } }), }); @@ -51,13 +142,65 @@ export class ScaffolderApi { throw new Error(`Backend request failed, ${status} ${body.trim()}`); } - const { id } = await response.json(); + const { id } = (await response.json()) as { id: string }; return id; } - async getJob(jobId: string) { + async getTask(taskId: string) { + const token = await this.identityApi.getIdToken(); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`; - return fetch(url).then(x => x.json()); + const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; + return fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }).then(x => x.json()); + } + + streamLogs({ + taskId, + after, + }: { + taskId: string; + after?: number; + }): Observable { + return new ObservableImpl(subscriber => { + const params = new URLSearchParams(); + if (after !== undefined) { + params.set('after', String(Number(after))); + } + + this.discoveryApi.getBaseUrl('scaffolder').then( + baseUrl => { + const url = `${baseUrl}/v2/tasks/${encodeURIComponent( + taskId, + )}/eventstream`; + const eventSource = new EventSource(url); + eventSource.addEventListener('log', (event: any) => { + if (event.data) { + try { + subscriber.next(JSON.parse(event.data)); + } catch (ex) { + subscriber.error(ex); + } + } + }); + eventSource.addEventListener('completion', (event: any) => { + if (event.data) { + try { + subscriber.next(JSON.parse(event.data)); + } catch (ex) { + subscriber.error(ex); + } + } + subscriber.complete(); + }); + eventSource.addEventListener('error', event => { + subscriber.error(event); + }); + }, + error => { + subscriber.error(error); + }, + ); + }); } } diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx deleted file mode 100644 index 8b3fc04de0..0000000000 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ /dev/null @@ -1,153 +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 { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - CircularProgress, - LinearProgress, - Typography, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import ExpandLessIcon from '@material-ui/icons/ExpandLess'; -import cn from 'classnames'; -import moment from 'moment'; -import React, { Suspense, useEffect, useState } from 'react'; -import { Job } from '../../types'; - -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); -moment.relativeTimeThreshold('ss', 0); - -const useStyles = makeStyles(theme => ({ - accordionDetails: { - padding: 0, - }, - button: { - order: -1, - margin: '0 1em 0 -20px', - }, - cardContent: { - backgroundColor: theme.palette.background.default, - }, - accordion: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - }, - }, - neutral: {}, - failed: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`, - }, - }, - started: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`, - }, - }, - completed: { - '&:after': { - boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, - }, - }, - jobStatusTitle: { - display: 'flex', - width: '100%', - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - [theme.breakpoints.down('xs')]: { - flexDirection: 'column', - alignItems: 'flex-start', - justifyContent: 'flex-start', - }, - }, -})); - -type Props = { - name: string; - className?: string; - log: string[]; - startedAt: string; - endedAt?: string; - status: Job['status']; -}; - -export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { - const classes = useStyles(); - - const [expanded, setExpanded] = useState(false); - useEffect(() => { - if (status === 'FAILED') setExpanded(true); - }, [status, setExpanded]); - - const timeElapsed = - status !== 'PENDING' - ? moment - .duration(moment(endedAt ?? moment()).diff(moment(startedAt))) - .humanize() - : null; - - return ( - ] ?? - classes.neutral, - )} - expanded={expanded} - onChange={(_, newState) => setExpanded(newState)} - > - : } - aria-controls={`panel-${name}-content`} - id={`panel-${name}-header`} - IconButtonProps={{ - className: classes.button, - }} - > - - {name} {timeElapsed && `(${timeElapsed})`}{' '} - {startedAt && !endedAt && } - - - - {log.length === 0 ? ( - - No logs available for this step - - ) : ( - }> -
- -
-
- )} -
-
- ); -}; diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx deleted file mode 100644 index 01a6346d24..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ /dev/null @@ -1,89 +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 { Button } from '@backstage/core'; -import { - Button as Action, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, -} from '@material-ui/core'; - -import React, { useCallback, useState } from 'react'; -import { Job } from '../../types'; -import { JobStage } from '../JobStage/JobStage'; - -type Props = { - job: Job | null; - toCatalogLink?: string; -}; - -export const JobStatusModal = ({ job, toCatalogLink }: Props) => { - const [isOpen, setOpen] = useState(true); - - const renderTitle = () => { - switch (job?.status) { - case 'COMPLETED': - return 'Successfully created component'; - case 'FAILED': - return 'Failed to create component'; - default: - return 'Create component'; - } - }; - - const onClose = useCallback(() => { - if (!job) { - return; - } - if (job.status === 'COMPLETED' || job.status === 'FAILED') { - setOpen(false); - } - }, [job]); - - return ( - - {renderTitle()} - - {!job ? ( - - ) : ( - (job?.stages ?? []).map(step => ( - - )) - )} - - {job?.status && toCatalogLink && ( - - - - )} - {job?.status === 'FAILED' && ( - - setOpen(false)}>Close - - )} - - ); -}; diff --git a/plugins/scaffolder/src/components/JobStatusModal/index.ts b/plugins/scaffolder/src/components/JobStatusModal/index.ts deleted file mode 100644 index 5598999fe3..0000000000 --- a/plugins/scaffolder/src/components/JobStatusModal/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { JobStatusModal } from './JobStatusModal'; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 528d42b94f..eb51e86d59 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JSONSchema } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; import { Content, StructuredMetadataTable } from '@backstage/core'; import { Box, Button, Paper, - Step, + Step as StepUI, StepContent, StepLabel, Stepper, @@ -28,16 +28,17 @@ import { import { FormProps, IChangeEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useState } from 'react'; +import { transformSchemaToProps } from './schema'; const Form = withTheme(MuiTheme); type Step = { - schema: JSONSchema; - label: string; + schema: JsonObject; + title: string; } & Partial, 'schema'>>; type Props = { /** - * Steps for the form, each contains label and form schema + * Steps for the form, each contains title and form schema */ steps: Step[]; formData: Record; @@ -66,20 +67,21 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map(({ label, schema, ...formProps }) => ( - - {label} - + {steps.map(({ title, schema, ...formProps }) => ( + + + {title} + +
['schema']} onSubmit={e => { if (e.errors.length === 0) handleNext(); }} {...formProps} + {...transformSchemaToProps(schema)} >
-
+ ))}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts new file mode 100644 index 0000000000..e02e5be01c --- /dev/null +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.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 { transformSchemaToProps } from './schema'; + +describe('transformSchemaToProps', () => { + it('transforms deep schema', () => { + const inputSchema = { + type: 'object', + properties: { + field1: { + type: 'string', + 'ui:derp': 'herp', + }, + field2: { + type: 'object', + properties: { + fieldX: { + type: 'string', + 'ui:derp': 'xerp', + }, + }, + }, + }, + }; + const expectedSchema = { + type: 'object', + properties: { + field1: { + type: 'string', + }, + field2: { + type: 'object', + properties: { + fieldX: { + type: 'string', + }, + }, + }, + }, + }; + const expectedUiSchema = { + field1: { + 'ui:derp': 'herp', + }, + field2: { + fieldX: { + 'ui:derp': 'xerp', + }, + }, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); +}); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts new file mode 100644 index 0000000000..7de3994a8e --- /dev/null +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -0,0 +1,71 @@ +/* + * 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 { FormProps } from '@rjsf/core'; + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { + const { properties } = schema; + if (!isObject(properties)) { + return; + } + for (const propName in properties) { + if (!properties.hasOwnProperty(propName)) { + continue; + } + const schemaNode = properties[propName]; + if (!isObject(schemaNode)) { + continue; + } + + if (schemaNode.type === 'object') { + const innerUiSchema = {}; + uiSchema[propName] = innerUiSchema; + extractUiSchema(schemaNode, innerUiSchema); + } else { + for (const innerKey in schemaNode) { + if (!schemaNode.hasOwnProperty(innerKey)) { + continue; + } + const innerValue = schemaNode[innerKey]; + if (innerKey.startsWith('ui:')) { + const innerUiSchema = uiSchema[propName] || {}; + if (!isObject(innerUiSchema)) { + throw new TypeError('Unexpected non-object in uiSchema'); + } + uiSchema[propName] = innerUiSchema; + + innerUiSchema[innerKey] = innerValue; + delete schemaNode[innerKey]; + } + } + } + } +} + +export function transformSchemaToProps( + inputSchema: JsonObject, +): { schema: FormProps['schema']; uiSchema: FormProps['uiSchema'] } { + const schema = JSON.parse(JSON.stringify(inputSchema)); + delete schema.title; // Rendered separately + const uiSchema = {}; + extractUiSchema(schema, uiSchema); + return { schema, uiSchema }; +} diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx new file mode 100644 index 0000000000..22de59b6ef --- /dev/null +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ResultsFilter } from './ResultsFilter'; + +describe('Results Filter', () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + tags: ['java'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity3', + tags: ['java', 'test'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + ] as Entity[], + }), + }; + + const identityApi: Partial = { + getUserId: () => 'tools@example.com', + }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + + it('should render all available categories', async () => { + const categories = ['test', 'java']; + const { findByText } = renderWrapped( + , + ); + for (const category of categories) { + expect( + await findByText(category.charAt(0).toUpperCase() + category.slice(1)), + ).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx new file mode 100644 index 0000000000..301c6b01d8 --- /dev/null +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.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 { + Button, + Checkbox, + Divider, + List, + ListItem, + ListItemText, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import React, { useContext } from 'react'; +import { filterGroupsContext } from '../../filter/context'; + +const useStyles = makeStyles(theme => ({ + filterBox: { + display: 'flex', + margin: theme.spacing(2, 0, 0, 0), + }, + filterBoxTitle: { + margin: theme.spacing(1, 0, 0, 1), + fontWeight: 'bold', + flex: 1, + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + checkbox: { + padding: theme.spacing(0, 1, 0, 1), + }, +})); + +type Props = { + availableCategories: string[]; +}; + +/** + * The additional results filter in the sidebar. + */ +export const ResultsFilter = ({ availableCategories }: Props) => { + const classes = useStyles(); + + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + + const { selectedCategories, setSelectedCategories } = context; + + return ( + <> +
+ + Refine Results + {' '} + +
+ + + Categories + + + {availableCategories.map(category => { + const labelId = `checkbox-list-label-${category}`; + return ( + + setSelectedCategories( + selectedCategories.includes(category) + ? selectedCategories.filter( + selectedCategory => selectedCategory !== category, + ) + : [...selectedCategories, category], + ) + } + > + + + + ); + })} + + + ); +}; diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx new file mode 100644 index 0000000000..12d4ecce82 --- /dev/null +++ b/plugins/scaffolder/src/components/Router.tsx @@ -0,0 +1,29 @@ +/* + * 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 { Routes, Route } from 'react-router'; +import { ScaffolderPage } from './ScaffolderPage'; +import { TemplatePage } from './TemplatePage'; +import { TaskPage } from './TaskPage'; + +export const Router = () => ( + + } /> + } /> + } /> + +); diff --git a/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx new file mode 100644 index 0000000000..b5ec45d8bb --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx @@ -0,0 +1,271 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ButtonGroup, ScaffolderFilter } from './ScaffolderFilter'; + +describe('Catalog Filter', () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + ] as Entity[], + }), + }; + + const identityApi: Partial = { + getUserId: () => 'tools@example.com', + }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + + describe('filter groups', () => { + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); + for (const group of mockGroups) { + expect(await findByText(group.name)).toBeInTheDocument(); + } + }); + }); + + describe('filter items', () => { + it('should render the different items and their names', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + for (const item of mockGroups[0].items) { + expect(await findByText(item.label)).toBeInTheDocument(); + } + }); + + it('selects the first item if no desired initial one is set', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + }); + + it('selects the initial item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('can change the selected item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'owned', + label: 'First Label', + filterFn: entity => entity.spec?.owner === 'tools@example.com', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx new file mode 100644 index 0000000000..14093462b8 --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx @@ -0,0 +1,211 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { IconComponent } from '@backstage/core'; +import { + Card, + List, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + makeStyles, + MenuItem, + Theme, + Typography, +} from '@material-ui/core'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; + +export type ButtonGroup = { + name: string; + items: { + id: string; + label: string; + icon?: IconComponent; + filterFn: (entity: Entity) => boolean; + }[]; +}; + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: 'rgba(0, 0, 0, .11)', + boxShadow: 'none', + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + listIcon: { + minWidth: 30, + color: theme.palette.text.primary, + }, + menuItem: { + minHeight: theme.spacing(6), + }, + groupWrapper: { + margin: theme.spacing(1, 1, 2, 1), + }, + menuTitle: { + fontWeight: 500, + }, +})); + +type OnChangeCallback = (item: { id: string; label: string }) => void; + +type Props = { + buttonGroups: ButtonGroup[]; + initiallySelected: string; + onChange?: OnChangeCallback; +}; + +/** + * The main filter group in the sidebar, toggling owned/starred/all. + */ +export const ScaffolderFilter = ({ + buttonGroups, + onChange, + initiallySelected, +}: Props) => { + const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter( + buttonGroups, + initiallySelected, + ); + + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const setCurrent = useCallback( + (item: { id: string; label: string }) => { + setCurrentFilter(item.id); + onChangeRef.current?.({ id: item.id, label: item.label }); + }, + [setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = buttonGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChangeRef.current?.({ id: item.id, label: item.label }); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {buttonGroups.map(group => ( + + + {group.name} + + + + {group.items.map(item => ( + setCurrent(item)} + selected={item.id === currentFilter} + className={classes.menuItem} + > + {item.icon && ( + + + + )} + + + {item.label} + + + + {getFilterCount(item.id) ?? '-'} + + + ))} + + + + ))} + + ); +}; + +function useFilter( + buttonGroups: ButtonGroup[], + initiallySelected: string, +): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState(initiallySelected); + + const filterGroup = useMemo( + () => ({ + filters: Object.fromEntries( + buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]), + ), + }), + [buttonGroups], + ); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + [initiallySelected], + ); + + const setCurrent = useCallback( + (filterId: string) => { + setCurrentFilter(filterId); + setSelectedFilters([filterId]); + }, + [setCurrentFilter, setSelectedFilters], + ); + + const getFilterCount = useCallback( + (filterId: string) => { + if (state.type !== 'ready') { + return undefined; + } + return state.state.filters[filterId].matchCount; + }, + [state], + ); + + return { + currentFilter, + setCurrentFilter: setCurrent, + getFilterCount, + }; +} diff --git a/plugins/scaffolder/src/components/ScaffolderFilter/index.ts b/plugins/scaffolder/src/components/ScaffolderFilter/index.ts new file mode 100644 index 0000000000..f53e4be89f --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderFilter/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 { ScaffolderFilter } from './ScaffolderFilter'; diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 39999f938f..3f6ceb64c7 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -14,11 +14,13 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { EntityMeta, TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { + configApiRef, Content, ContentHeader, - errorApiRef, Header, Lifecycle, Page, @@ -27,12 +29,24 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { Button, Grid, Link, Typography } from '@material-ui/core'; -import React, { useEffect } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import useStaleWhileRevalidate from 'swr'; +import { useStarredEntities } from '@backstage/plugin-catalog-react'; +import { Button, Grid, Link, makeStyles, Typography } from '@material-ui/core'; +import StarIcon from '@material-ui/icons/Star'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { TemplateCard, TemplateCardProps } from '../TemplateCard'; +import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; +import { ScaffolderFilter } from '../ScaffolderFilter'; +import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter'; +import SearchToolbar from '../SearchToolbar/SearchToolbar'; + +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'grid'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, +})); const getTemplateCardProps = ( template: TemplateEntityV1alpha1, @@ -47,24 +61,62 @@ const getTemplateCardProps = ( }; }; -export const ScaffolderPage = () => { - const catalogApi = useApi(catalogApiRef); - const errorApi = useApi(errorApiRef); - - const { data: templates, isValidating, error } = useStaleWhileRevalidate( - 'templates/all', - async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template' }, - }); - return response.items as TemplateEntityV1alpha1[]; - }, +export const ScaffolderPageContents = () => { + const styles = useStyles(); + const { + loading, + error, + filteredEntities, + availableCategories, + } = useFilteredEntities(); + const configApi = useApi(configApiRef); + const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; + const { isStarredEntity } = useStarredEntities(); + const filterGroups = useMemo( + () => [ + { + name: orgName, + items: [ + { + id: 'all', + label: 'All', + filterFn: () => true, + }, + ], + }, + { + name: 'Personal', + items: [ + { + id: 'starred', + label: 'Starred', + icon: StarIcon, + filterFn: isStarredEntity, + }, + ], + }, + ], + [isStarredEntity, orgName], + ); + const [search, setSearch] = useState(''); + const [matchingEntities, setMatchingEntities] = useState( + [] as TemplateEntityV1alpha1[], ); + const matchesQuery = (metadata: EntityMeta, query: string) => + `${metadata.title}`.toUpperCase().includes(query) || + metadata.tags?.join('').toUpperCase().indexOf(query) !== -1; + useEffect(() => { - if (!error) return; - errorApi.post(error); - }, [error, errorApi]); + if (search.length === 0) { + return setMatchingEntities(filteredEntities); + } + return setMatchingEntities( + filteredEntities.filter(template => + matchesQuery(template.metadata, search.toUpperCase()), + ), + ); + }, [search, filteredEntities]); return ( @@ -93,33 +145,64 @@ export const ScaffolderPage = () => { documentation, ...). - {!templates && isValidating && } - {templates && !templates.length && ( - - Shoot! Looks like you don't have any templates. Check out the - documentation{' '} - - here! - - - )} - {error && ( - - Oops! Something went wrong loading the templates: {error.message} - - )} - - {templates && - templates?.length > 0 && - templates.map(template => { - return ( - - - - ); - })} - + +
+
+ + + +
+
+ {loading && } + + {error && ( + + {error.message} + + )} + + {!error && + !loading && + matchingEntities && + !matchingEntities.length && ( + + No templates found that match your filter. Learn more about{' '} + + adding templates + + . + + )} + + + {matchingEntities && + matchingEntities?.length > 0 && + matchingEntities.map(template => { + return ( + + + + ); + })} + +
+
); }; + +export const ScaffolderPage = () => ( + + + +); diff --git a/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.test.tsx b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.test.tsx new file mode 100644 index 0000000000..7b9a64b935 --- /dev/null +++ b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.test.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 React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import SearchToolbar from './SearchToolbar'; + +describe('SearchToolbar', () => { + it('should display search value and execute set callback', async () => { + const setSearchSpy = jest.fn(); + const { getByDisplayValue } = render( + , + ); + + const searchInput = getByDisplayValue('hello'); + expect(searchInput).toBeInTheDocument(); + fireEvent.change(searchInput, { target: { value: 'world' } }); + expect(setSearchSpy).toHaveBeenCalled(); + }); +}); diff --git a/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx new file mode 100644 index 0000000000..4a50fcb257 --- /dev/null +++ b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.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 { + FormControl, + InputAdornment, + makeStyles, + Toolbar, + Input, + IconButton, +} from '@material-ui/core'; +import Search from '@material-ui/icons/Search'; +import Clear from '@material-ui/icons/Clear'; + +interface Props { + search: string; + setSearch: Function; +} + +const useStyles = makeStyles(_theme => ({ + searchToolbar: { + paddingLeft: 0, + paddingRight: 0, + }, +})); + +const SearchToolbar = ({ search, setSearch }: Props) => { + const styles = useStyles(); + return ( + + + setSearch(event.target.value)} + value={search} + startAdornment={ + + + + } + endAdornment={ + + setSearch('')} + edge="end" + disabled={search.length === 0} + > + + + + } + /> + + + ); +}; + +export default SearchToolbar; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx new file mode 100644 index 0000000000..96a857dbb5 --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -0,0 +1,338 @@ +/* + * 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 { Page, Header, Lifecycle, Content, ErrorPage } from '@backstage/core'; +import React, { useState, useEffect, memo, useMemo } from 'react'; +import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import Stepper from '@material-ui/core/Stepper'; +import Step from '@material-ui/core/Step'; +import StepLabel from '@material-ui/core/StepLabel'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import { generatePath, useParams } from 'react-router'; +import { useTaskEventStream } from '../hooks/useEventStream'; +import LazyLog from 'react-lazylog/build/LazyLog'; +import { Link } from 'react-router-dom'; +import { + Box, + Button, + CircularProgress, + Paper, + StepButton, + StepIconProps, +} from '@material-ui/core'; +import { Status } from '../../types'; +import { DateTime, Interval } from 'luxon'; +import { useInterval } from 'react-use'; +import Check from '@material-ui/icons/Check'; +import Cancel from '@material-ui/icons/Cancel'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import { entityRoute } from '@backstage/plugin-catalog-react'; +import { parseEntityName } from '@backstage/catalog-model'; +import classNames from 'classnames'; +import { BackstageTheme } from '@backstage/theme'; + +// typings are wrong for this library, so fallback to not parsing types. +const humanizeDuration = require('humanize-duration'); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + }, + button: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + }, + actionsContainer: { + marginBottom: theme.spacing(2), + }, + resetContainer: { + padding: theme.spacing(3), + }, + labelWrapper: { + display: 'flex', + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + }, + stepWrapper: { + width: '100%', + }, + }), +); + +type TaskStep = { + id: string; + name: string; + status: Status; + startedAt?: string; + endedAt?: string; +}; + +const StepTimeTicker = ({ step }: { step: TaskStep }) => { + const [time, setTime] = useState(''); + + useInterval(() => { + if (!step.startedAt) { + setTime(''); + return; + } + + const end = step.endedAt + ? DateTime.fromISO(step.endedAt) + : DateTime.local(); + + const startedAt = DateTime.fromISO(step.startedAt); + const formatted = Interval.fromDateTimes(startedAt, end) + .toDuration() + .valueOf(); + + setTime(humanizeDuration(formatted, { round: true })); + }, 1000); + + return {time}; +}; + +const useStepIconStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + root: { + color: theme.palette.text.disabled, + display: 'flex', + height: 22, + alignItems: 'center', + }, + completed: { + color: theme.palette.status.ok, + }, + error: { + color: theme.palette.status.error, + }, + }), +); + +function TaskStepIconComponent(props: StepIconProps) { + const classes = useStepIconStyles(); + const { active, completed, error } = props; + + const getMiddle = () => { + if (active) { + return ; + } + if (completed) { + return ; + } + if (error) { + return ; + } + return ; + }; + + return ( +
+ {getMiddle()} +
+ ); +} + +export const TaskStatusStepper = memo( + ({ + steps, + currentStepId, + onUserStepChange, + }: { + steps: TaskStep[]; + currentStepId: string | undefined; + onUserStepChange: (id: string) => void; + }) => { + const classes = useStyles(); + + return ( +
+ s.id === currentStepId)} + orientation="vertical" + nonLinear + > + {steps.map((step, index) => { + const isCompleted = step.status === 'completed'; + const isFailed = step.status === 'failed'; + const isActive = step.status === 'processing'; + return ( + + onUserStepChange(step.id)}> + +
+ {step.name} + +
+
+
+
+ ); + })} +
+
+ ); + }, +); + +const TaskLogger = memo(({ log }: { log: string }) => { + return ( +
+ +
+ ); +}); + +export const TaskPage = () => { + const [userSelectedStepId, setUserSelectedStepId] = useState< + string | undefined + >(undefined); + const [lastActiveStepId, setLastActiveStepId] = useState( + undefined, + ); + const { taskId } = useParams(); + const taskStream = useTaskEventStream(taskId); + const completed = taskStream.completed; + const steps = useMemo( + () => + taskStream.task?.spec.steps.map(step => ({ + ...step, + ...taskStream?.steps?.[step.id], + })) ?? [], + [taskStream], + ); + + useEffect(() => { + const mostRecentFailedOrActiveStep = steps.find(step => + ['failed', 'processing'].includes(step.status), + ); + if (completed && !mostRecentFailedOrActiveStep) { + setLastActiveStepId(steps[steps.length - 1]?.id); + return; + } + + setLastActiveStepId(mostRecentFailedOrActiveStep?.id); + }, [steps, completed]); + + const currentStepId = userSelectedStepId ?? lastActiveStepId; + + const logAsString = useMemo(() => { + if (!currentStepId) { + return 'Loading...'; + } + const log = taskStream.stepLogs[currentStepId]; + + if (!log?.length) { + return 'Waiting for logs...'; + } + return log.join('\n'); + }, [taskStream.stepLogs, currentStepId]); + + const taskNotFound = + taskStream.completed === true && + taskStream.loading === false && + !taskStream.task; + + const entityRef = taskStream.output?.entityRef; + const remoteUrl = taskStream.output?.remoteUrl; + return ( + +
+ Task Activity + + } + subtitle={`Activity for task: ${taskId}`} + /> + + {taskNotFound ? ( + + ) : ( +
+ + + + + {(entityRef || remoteUrl) && ( + + {entityRef && ( + + )} + {remoteUrl && ( + + )} + + )} + + + + + + +
+ )} +
+ + ); +}; diff --git a/plugins/scaffolder/src/components/TaskPage/index.ts b/plugins/scaffolder/src/components/TaskPage/index.ts new file mode 100644 index 0000000000..3695c2792e --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/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 { TaskPage } from './TaskPage'; diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 732b5f3e44..c38f4e09a5 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -13,18 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button } from '@backstage/core'; +import { Button, useRouteRef } from '@backstage/core'; import { BackstageTheme, pageTheme } from '@backstage/theme'; import { Card, + CardActions, + CardContent, Chip, makeStyles, Typography, useTheme, } from '@material-ui/core'; import React from 'react'; -import { generatePath } from 'react-router-dom'; -import { templateRoute } from '../../routes'; +import { generatePath } from 'react-router'; +import { rootRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ header: { @@ -34,18 +36,11 @@ const useStyles = makeStyles(theme => ({ props.backgroundImage, backgroundPosition: 0, }, - content: { - padding: theme.spacing(2), - }, description: { height: 175, overflow: 'hidden', textOverflow: 'ellipsis', }, - footer: { - display: 'flex', - flexDirection: 'row-reverse', - }, })); export type TemplateCardProps = { @@ -64,11 +59,14 @@ export const TemplateCard = ({ name, }: TemplateCardProps) => { const backstageTheme = useTheme(); + const rootLink = useRouteRef(rootRouteRef); const themeId = pageTheme[type] ? type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); - const href = generatePath(templateRoute.path, { templateName: name }); + const href = generatePath(`${rootLink()}/templates/:templateName`, { + templateName: name, + }); return ( @@ -76,19 +74,19 @@ export const TemplateCard = ({ {type} {title} -
+ {tags?.map(tag => ( ))} {description} -
- -
-
+ + + +
); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 62963b58b8..1d66e07c6a 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; @@ -22,48 +21,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; -import { rootRoute } from '../../routes'; +import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; -const templateMock = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/something/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.', - tags: ['Recommended', 'React'], - uid: '55efc748-4a2b-460f-9e47-3f4fd23b46f7', - etag: 'MTM3YThjY2QtYTc1MS00MTFkLTk3YTAtNzgyMDg3MDVmZTVm', - generation: 1, - }, - spec: { - processor: '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', - }, - }, - }, - }, -}; - jest.mock('react-router-dom', () => { return { ...(jest.requireActual('react-router-dom') as any), @@ -73,35 +33,41 @@ jest.mock('react-router-dom', () => { }; }); -const scaffolderApiMock: Partial = { +const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), + getTemplateParameterSchema: jest.fn(), + getTask: jest.fn(), + streamLogs: jest.fn(), }; -const catalogApiMock = { - getEntities: jest.fn() as jest.MockedFunction, -}; const errorApiMock = { post: jest.fn(), error$: jest.fn() }; const apis = ApiRegistry.from([ [scaffolderApiRef, scaffolderApiMock], [errorApiRef, errorApiMock], - [catalogApiRef, catalogApiMock], ]); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); it('renders correctly', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] }); + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({ + title: 'React SSR Template', + steps: [], + }); const rendered = await renderInTestApp( , + { + mountedRoutes: { + '/create': rootRouteRef, + }, + }, ); - expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); + expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByText('React SSR Template')).toBeInTheDocument(); - // await act(async () => await mutate('templates/test')); }); it('renders spinner while loading', async () => { @@ -109,23 +75,33 @@ describe('TemplatePage', () => { const promise = new Promise(res => { resolve = res; }); - catalogApiMock.getEntities.mockReturnValueOnce(promise); + scaffolderApiMock.getTemplateParameterSchema.mockReturnValueOnce(promise); const rendered = await renderInTestApp( , + { + mountedRoutes: { + '/create': rootRouteRef, + }, + }, ); - expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); + expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); - // Need to cleanup the promise or will timeout - act(() => { - resolve!({ items: [] }); + + await act(async () => { + resolve!({ + title: 'React SSR Template', + steps: [], + }); }); }); it('navigates away if no template was loaded', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] }); + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue( + undefined as any, + ); const rendered = await renderWithEffects( @@ -134,14 +110,14 @@ describe('TemplatePage', () => { - This is root} /> + This is root} /> , ); expect( - rendered.queryByText('Create a new component'), + rendered.queryByText('Create a New Component'), ).not.toBeInTheDocument(); expect(rendered.queryByText('This is root')).toBeInTheDocument(); }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0754b64d7f..1c8a662acb 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Content, errorApiRef, @@ -22,164 +21,130 @@ import { Lifecycle, Page, useApi, + useRouteRef, } from '@backstage/core'; -import { - catalogApiRef, - entityRoute, - entityRouteParams, -} from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; -import { IChangeEvent } from '@rjsf/core'; -import React, { useState, useCallback } from 'react'; -import { generatePath, Navigate } from 'react-router'; +import { FormValidation, IChangeEvent } from '@rjsf/core'; +import parseGitUrl from 'git-url-parse'; +import React, { useCallback, useState } from 'react'; +import { generatePath, useNavigate, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { rootRoute } from '../../routes'; -import { JobStatusModal } from '../JobStatusModal'; +import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { useJobPolling } from '../hooks/useJobPolling'; -const useTemplate = ( - templateName: string, - catalogApi: typeof catalogApiRef.T, +const useTemplateParameterSchema = (templateName: string) => { + const scaffolderApi = useApi(scaffolderApiRef); + const { value, loading, error } = useAsync( + () => + scaffolderApi.getTemplateParameterSchema({ + name: templateName, + kind: 'template', + namespace: 'default', + }), + [scaffolderApi, templateName], + ); + return { schema: value, loading, error }; +}; + +const storePathValidator = ( + formData: { storePath?: string }, + errors: FormValidation, ) => { - const { value, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template', 'metadata.name': templateName }, - }); - return response.items as TemplateEntityV1alpha1[]; - }); - return { template: value?.[0], loading, error }; -}; + const { storePath } = formData; + if (!storePath) { + return errors; + } -const OWNER_REPO_SCHEMA = { - $schema: 'http://json-schema.org/draft-07/schema#' as const, - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string' as const, - title: 'Owner', - 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', - }, - access: { - type: 'string' as const, - title: 'Access', - description: 'Who should have access, in org/team or user format', - }, - }, -}; + try { + const parsedUrl = parseGitUrl(storePath); -const REPO_FORMAT = { - 'GitHub user or org / Repo name': /[^\/]*\/[^\/]*/, + 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 path with message ${ex.message}`, + ); + } + + return errors; }; export const TemplatePage = () => { const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); - const [catalogLink, setCatalogLink] = useState(); - const { template, loading } = useTemplate(templateName, catalogApi); + const navigate = useNavigate(); + const rootLink = useRouteRef(rootRouteRef); + const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); - const [modalOpen, setModalOpen] = useState(false); const handleFormReset = () => setFormState({}); + const handleChange = useCallback( (e: IChangeEvent) => setFormState({ ...formState, ...e.formData }), [setFormState, formState], ); - const [jobId, setJobId] = useState(null); - const job = useJobPolling(jobId, async job => { - if (!job.metadata.catalogInfoUrl) { - errorApi.post( - new Error(`No catalogInfoUrl returned from the scaffolder`), - ); - return; - } - - try { - const { - entities: [createdEntity], - } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); - - const resolvedPath = generatePath( - `/catalog/${entityRoute.path}`, - entityRouteParams(createdEntity), - ); - - setCatalogLink(resolvedPath); - } catch (ex) { - errorApi.post( - new Error( - `Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`, - ), - ); - } - }); - const handleCreate = async () => { try { - const jobId = await scaffolderApi.scaffold(templateName, formState); - setJobId(jobId); - setModalOpen(true); + const id = await scaffolderApi.scaffold(templateName, formState); + + navigate(generatePath(`${rootLink()}/tasks/:taskId`, { taskId: id })); } catch (e) { errorApi.post(e); } }; - if (!loading && !template) { - errorApi.post(new Error('Template was not found.')); - return ; + if (error) { + errorApi.post(new Error(`Failed to load template, ${error}`)); + return ; } - - if (template && !template?.spec?.schema) { - errorApi.post( - new Error( - 'Template schema is corrupted, please check the template.yaml file.', - ), - ); - return ; + if (!loading && !schema) { + errorApi.post(new Error('Template was not found.')); + return ; } return (
- Create a new component + Create a New Component } subtitle="Create new software components using standard templates" /> {loading && } - {modalOpen && } - {template && ( - + {schema && ( + { + // TODO: Using this workaround to keep storePath validation, but we should replace + // it with a custom store path selection widget + if ((step.schema as any)?.properties?.storePath) { + return { + ...step, + validate: (a, b) => storePathValidator(a, b), + }; + } + return step; + })} /> )} diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts new file mode 100644 index 0000000000..210fd8cb9b --- /dev/null +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -0,0 +1,211 @@ +/* + * 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 { useImmerReducer } from 'use-immer'; +import { useEffect } from 'react'; +import { scaffolderApiRef, LogEvent } from '../../api'; +import { ScaffolderTask, Status } from '../../types'; +import { Subscription, useApi } from '@backstage/core'; + +type Step = { + id: string; + status: Status; + endedAt?: string; + startedAt?: string; +}; + +type TaskOutput = { entityRef?: string } & { [key in string]: string }; + +export type TaskStream = { + loading: boolean; + error?: Error; + stepLogs: { [stepId in string]: string[] }; + completed: boolean; + task?: ScaffolderTask; + steps: { [stepId in string]: Step }; + output?: TaskOutput; +}; + +type ReducerLogEntry = { + createdAt: string; + body: { + stepId?: string; + status?: Status; + message: string; + output?: TaskOutput; + }; +}; + +type ReducerAction = + | { type: 'INIT'; data: ScaffolderTask } + | { type: 'LOGS'; data: ReducerLogEntry[] } + | { type: 'COMPLETED'; data: ReducerLogEntry } + | { type: 'ERROR'; data: Error }; + +function reducer(draft: TaskStream, action: ReducerAction) { + switch (action.type) { + case 'INIT': { + draft.steps = action.data.spec.steps.reduce((current, next) => { + current[next.id] = { status: 'open', id: next.id }; + return current; + }, {} as { [stepId in string]: Step }); + draft.stepLogs = action.data.spec.steps.reduce((current, next) => { + current[next.id] = []; + return current; + }, {} as { [stepId in string]: string[] }); + draft.loading = false; + draft.error = undefined; + draft.completed = false; + draft.task = action.data; + return; + } + + case 'LOGS': { + const entries = action.data; + const logLines = []; + + for (const entry of entries) { + const logLine = `${entry.createdAt} ${entry.body.message}`; + logLines.push(logLine); + + if (!entry.body.stepId || !draft.steps?.[entry.body.stepId]) { + continue; + } + + const currentStepLog = draft.stepLogs?.[entry.body.stepId]; + const currentStep = draft.steps?.[entry.body.stepId]; + + if (entry.body.status && entry.body.status !== currentStep.status) { + currentStep.status = entry.body.status; + + if (currentStep.status === 'processing') { + currentStep.startedAt = entry.createdAt; + } + + if ( + ['cancelled', 'failed', 'completed'].includes(currentStep.status) + ) { + currentStep.endedAt = entry.createdAt; + } + } + + currentStepLog?.push(logLine); + } + + return; + } + + case 'COMPLETED': { + draft.completed = true; + draft.output = action.data.body.output; + return; + } + + case 'ERROR': { + draft.error = action.data; + draft.loading = false; + draft.completed = true; + return; + } + + default: + return; + } +} + +export const useTaskEventStream = (taskId: string): TaskStream => { + const scaffolderApi = useApi(scaffolderApiRef); + const [state, dispatch] = useImmerReducer(reducer, { + loading: true, + completed: false, + stepLogs: {} as { [stepId in string]: string[] }, + steps: {} as { [stepId in string]: Step }, + }); + + useEffect(() => { + let didCancel = false; + let subscription: Subscription | undefined; + let logPusher: NodeJS.Timeout | undefined; + + scaffolderApi.getTask(taskId).then( + task => { + if (didCancel) { + return; + } + dispatch({ type: 'INIT', data: task }); + + // TODO(blam): Use a normal fetch to fetch the current log for the event stream + // and use that for an INIT_EVENTs dispatch event, and then + // use the last event ID to subscribe using after option to + // stream logs. Without this, if you have a lot of logs, it can look like the + // task is being rebuilt on load as it progresses through the steps at a slower + // rate whilst it builds the status from the event logs + const observable = scaffolderApi.streamLogs({ taskId }); + + const collectedLogEvents = new Array(); + + function emitLogs() { + if (collectedLogEvents.length) { + const logs = collectedLogEvents.splice( + 0, + collectedLogEvents.length, + ); + dispatch({ type: 'LOGS', data: logs }); + } + } + + logPusher = setInterval(emitLogs, 500); + + subscription = observable.subscribe({ + next: event => { + switch (event.type) { + case 'log': + return collectedLogEvents.push(event); + case 'completion': + emitLogs(); + dispatch({ type: 'COMPLETED', data: event }); + return undefined; + default: + throw new Error( + `Unhandled event type ${event.type} in observer`, + ); + } + }, + error: error => { + emitLogs(); + dispatch({ type: 'ERROR', data: error }); + }, + }); + }, + error => { + if (!didCancel) { + dispatch({ type: 'ERROR', data: error }); + } + }, + ); + + return () => { + didCancel = true; + if (subscription) { + subscription.unsubscribe(); + } + if (logPusher) { + clearInterval(logPusher); + } + }; + }, [scaffolderApi, dispatch, taskId]); + + return state; +}; diff --git a/plugins/scaffolder/src/components/hooks/useJobPolling.ts b/plugins/scaffolder/src/components/hooks/useJobPolling.ts deleted file mode 100644 index 1bfdfc53b8..0000000000 --- a/plugins/scaffolder/src/components/hooks/useJobPolling.ts +++ /dev/null @@ -1,49 +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 { useState } from 'react'; -import { Job } from '../../types'; -import { useApi } from '@backstage/core'; -import { scaffolderApiRef } from '../../api'; -import { useInterval } from 'react-use'; - -const DEFAULT_POLLING_INTERVAL = 1000; - -export const useJobPolling = ( - jobId: string | null, - onFinish?: (j: Job) => void, - pollingInterval = DEFAULT_POLLING_INTERVAL, -) => { - const scaffolderApi = useApi(scaffolderApiRef); - const [currentJob, setCurrentJob] = useState(null); - const shouldBeRunningInterval = - jobId && - currentJob?.status !== 'COMPLETED' && - currentJob?.status !== 'FAILED'; - useInterval( - async () => { - if (jobId) { - const job = await scaffolderApi.getJob(jobId); - if (job?.status === 'COMPLETED' || job?.status === 'FAILED') { - onFinish?.(job); - } - setCurrentJob(job); - } - }, - shouldBeRunningInterval ? pollingInterval : null, - ); - - return currentJob; -}; diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx new file mode 100644 index 0000000000..9e11525347 --- /dev/null +++ b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx @@ -0,0 +1,263 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAsyncFn } from 'react-use'; +import { filterGroupsContext, FilterGroupsContext } from './context'; +import { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, +} from './types'; + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const state = useProvideEntityFilters(); + return ( + + {children} + + ); +}; + +// The hook that implements the actual context building +function useProvideEntityFilters(): FilterGroupsContext { + const catalogApi = useApi(catalogApiRef); + const [{ value: entities, error }, doReload] = useAsyncFn(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template' }, + }); + return response.items as TemplateEntityV1alpha1[]; + }); + + const filterGroups = useRef<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const selectedFilterKeys = useRef<{ + [filterGroupId: string]: Set; + }>({}); + const selectedCategories = useRef([]); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [filteredEntities, setFilteredEntities] = useState< + TemplateEntityV1alpha1[] + >([]); + const [availableCategories, setAvailableCategories] = useState([]); + const [isCatalogEmpty, setCatalogEmpty] = useState(false); + + useEffect(() => { + doReload(); + }, [doReload]); + + const rebuild = useCallback(() => { + setFilterGroupStates( + buildStates( + filterGroups.current, + selectedFilterKeys.current, + selectedCategories.current, + entities, + error, + ), + ); + setFilteredEntities( + buildMatchingEntities( + filterGroups.current, + selectedFilterKeys.current, + selectedCategories.current, + entities, + ), + ); + setAvailableCategories(collectCategories(entities)); + setCatalogEmpty(entities !== undefined && entities.length === 0); + }, [entities, error]); + + const register = useCallback( + ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => { + filterGroups.current[filterGroupId] = filterGroup; + selectedFilterKeys.current[filterGroupId] = new Set( + initialSelectedFilterIds ?? [], + ); + rebuild(); + }, + [rebuild], + ); + + const unregister = useCallback( + (filterGroupId: string) => { + delete filterGroups.current[filterGroupId]; + delete selectedFilterKeys.current[filterGroupId]; + rebuild(); + }, + [rebuild], + ); + + const setGroupSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + selectedFilterKeys.current[filterGroupId] = new Set(filters); + rebuild(); + }, + [rebuild], + ); + + const setSelectedCategories = useCallback( + (categories: string[]) => { + selectedCategories.current = categories; + rebuild(); + }, + [rebuild], + ); + + const reload = useCallback(async () => { + await doReload(); + }, [doReload]); + + return { + register, + unregister, + setGroupSelectedFilters, + setSelectedCategories, + reload, + selectedCategories: selectedCategories.current, + loading: !error && !entities, + error, + filterGroupStates, + filteredEntities, + availableCategories, + isCatalogEmpty, + }; +} + +// Given all filter groups and what filters are actually selected, along with +// the loading state for entities, generate the state of each individual filter +function buildStates( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + selectedCategories: string[], + entities?: TemplateEntityV1alpha1[], + error?: Error, +): { [filterGroupId: string]: FilterGroupStates } { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities( + filterGroups, + selectedFilterKeys, + selectedCategories, + entities, + filterGroupId, + ); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; +} + +// Given all entites, find all possible categories and provide them in a sorted list. +function collectCategories(entities?: TemplateEntityV1alpha1[]): string[] { + const categories = new Set(); + (entities || []).forEach(e => { + if (e.spec?.type) { + categories.add(e.spec.type as string); + } + }); + return Array.from(categories).sort(); +} + +// Given all filter groups and what filters are actually selected, extract all +// entities that match all those filter groups. +function buildMatchingEntities( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + selectedCategories: string[], + entities?: TemplateEntityV1alpha1[], + excludeFilterGroupId?: string, +): TemplateEntityV1alpha1[] { + // Build one filter fn per filter group + const allFilters: EntityFilterFn[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: EntityFilterFn[] = []; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // Filter by categories, if at least one category is selected. + if (selectedCategories.length > 0) { + allFilters.push(entity => + selectedCategories.some(c => entity.spec?.type === c), + ); + } + + // All filter groups that had any checked filters need to match. Note that + // every() always returns true for an empty array. + return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? []; +} diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts new file mode 100644 index 0000000000..a7819be752 --- /dev/null +++ b/plugins/scaffolder/src/filter/context.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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { createContext } from 'react'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type FilterGroupsContext = { + register: ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => void; + unregister: (filterGroupId: string) => void; + setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; + setSelectedCategories: (categories: string[]) => void; + reload: () => Promise; + selectedCategories: string[]; + loading: boolean; + error?: Error; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + filteredEntities: TemplateEntityV1alpha1[]; + availableCategories: string[]; + isCatalogEmpty: boolean; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext< + FilterGroupsContext | undefined +>(undefined); diff --git a/plugins/scaffolder/src/filter/index.ts b/plugins/scaffolder/src/filter/index.ts new file mode 100644 index 0000000000..da73147ef9 --- /dev/null +++ b/plugins/scaffolder/src/filter/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +export type { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, + FilterGroupStatesError, + FilterGroupStatesLoading, + FilterGroupStatesReady, +} from './types'; +export { useEntityFilterGroup } from './useEntityFilterGroup'; +export { useFilteredEntities } from './useFilteredEntities'; diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/scaffolder/src/filter/types.ts similarity index 51% rename from plugins/api-docs/src/components/EntityLink/EntityLink.tsx rename to plugins/scaffolder/src/filter/types.ts index 98b2103882..ed08b131bf 100644 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ b/plugins/scaffolder/src/filter/types.ts @@ -15,26 +15,39 @@ */ 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; +export type EntityFilterFn = (entity: Entity) => boolean; + +export type FilterGroup = { + filters: { + [filterId: string]: EntityFilterFn; + }; }; -// TODO: Could be useful for others too, as part of the catalog plugin -export const EntityLink = ({ entity, children }: PropsWithChildren) => { - return ( - - {children} - - ); +export type FilterGroupState = { + filters: { + [filterId: string]: { + isSelected: boolean; + matchCount: number; + }; + }; }; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; diff --git a/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx b/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..685fc751d2 --- /dev/null +++ b/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { MockStorageApi } from '@backstage/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +import { FilterGroup, FilterGroupStatesReady } from './types'; +import { useEntityFilterGroup } from './useEntityFilterGroup'; + +describe('useEntityFilterGroup', () => { + let catalogApi: jest.Mocked; + let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + storageApiRef, + MockStorageApi.create(), + ); + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + const group: FilterGroup = { filters: {} }; + const { result, waitFor } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await waitFor(() => expect(result.current.state.type).toBe('ready')); + }); + + it('works for a single group', async () => { + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ], + }); + const group: FilterGroup = { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }; + const { result, waitFor } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + let state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f1'])); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: true, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f2'])); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: true, + matchCount: 0, + }); + }); +}); diff --git a/plugins/scaffolder/src/filter/useEntityFilterGroup.ts b/plugins/scaffolder/src/filter/useEntityFilterGroup.ts new file mode 100644 index 0000000000..242238e4f4 --- /dev/null +++ b/plugins/scaffolder/src/filter/useEntityFilterGroup.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { filterGroupsContext } from './context'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type EntityFilterGroupOutput = { + state: FilterGroupStates; + setSelectedFilters: (filterIds: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilters?: string[], +): EntityFilterGroupOutput => { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + const { + register, + unregister, + setGroupSelectedFilters, + filterGroupStates, + } = context; + + // Intentionally consider initial set only at mount time + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []); + + // Register the group on mount, and unregister on unmount + useEffect(() => { + register(filterGroupId, filterGroup, initialMemo); + return () => unregister(filterGroupId); + }, [register, unregister, filterGroupId, filterGroup, initialMemo]); + + const setSelectedFilters = useCallback( + (filters: string[]) => { + setGroupSelectedFilters(filterGroupId, filters); + }, + [setGroupSelectedFilters, filterGroupId], + ); + + let state = filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, setSelectedFilters }; +}; diff --git a/plugins/scaffolder/src/filter/useFilteredEntities.ts b/plugins/scaffolder/src/filter/useFilteredEntities.ts new file mode 100644 index 0000000000..d3eb553687 --- /dev/null +++ b/plugins/scaffolder/src/filter/useFilteredEntities.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 { useContext } from 'react'; +import { filterGroupsContext } from './context'; + +/** + * Hook that exposes the result of applying a set of filter groups. + */ +export function useFilteredEntities() { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + + return { + loading: context.loading, + error: context.error, + filteredEntities: context.filteredEntities, + availableCategories: context.availableCategories, + isCatalogEmpty: context.isCatalogEmpty, + reload: context.reload, + }; +} diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index f0fdf5b429..e0b574e6c6 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,6 +14,10 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { ScaffolderApi, scaffolderApiRef } from './api'; -export { rootRoute, templateRoute } from './routes'; +export { + scaffolderPlugin, + scaffolderPlugin as plugin, + ScaffolderPage, +} from './plugin'; +export type { ScaffolderApi } from './api'; +export { ScaffolderClient, scaffolderApiRef } from './api'; 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..a6ba1a9899 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -18,23 +18,30 @@ import { createPlugin, createApiFactory, discoveryApiRef, + identityApiRef, + createRoutableExtension, } from '@backstage/core'; -import { ScaffolderPage } from './components/ScaffolderPage'; -import { TemplatePage } from './components/TemplatePage'; -import { rootRoute, templateRoute } from './routes'; -import { scaffolderApiRef, ScaffolderApi } from './api'; +import { rootRouteRef } from './routes'; +import { scaffolderApiRef, ScaffolderClient } from './api'; -export const plugin = createPlugin({ +export const scaffolderPlugin = createPlugin({ id: 'scaffolder', apis: [ createApiFactory({ api: scaffolderApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new ScaffolderClient({ discoveryApi, identityApi }), }), ], - register({ router }) { - router.addRoute(rootRoute, ScaffolderPage); - router.addRoute(templateRoute, TemplatePage); + routes: { + root: rootRouteRef, }, }); + +export const ScaffolderPage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 28c77f29a6..413e8f4194 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -15,13 +15,6 @@ */ import { createRouteRef } from '@backstage/core'; -export const rootRoute = createRouteRef({ - icon: () => null, - path: '/create', +export const rootRouteRef = createRouteRef({ title: 'Create new entity', }); -export const templateRoute = createRouteRef({ - icon: () => null, - path: '/create/:templateName', - title: 'Entity creation', -}); diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 45672c603d..0499f4aca2 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonValue } from '@backstage/config'; +export type Status = 'open' | 'processing' | 'failed' | 'completed'; export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; export type Job = { id: string; @@ -35,3 +37,20 @@ export type Stage = { startedAt: string; endedAt?: string; }; + +export type ScaffolderStep = { + id: string; + name: string; + action: string; + parameters?: { [name: string]: JsonValue }; +}; + +export type ScaffolderTask = { + id: string; + spec: { + steps: ScaffolderStep[]; + }; + status: 'failed' | 'completed' | 'processing' | 'open' | 'cancelled'; + lastHeartbeatAt: string; + createdAt: string; +}; diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 106499bad4..a4715a9ce1 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,128 @@ # @backstage/plugin-search +## 0.3.2 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.0 + +### Minor Changes + +- b3f0c3811: 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. + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.2.3 + +### Patch Changes + +- 6a0d7a9fb: change default size for pageSize in search result view +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.2 ### 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 490d5003ef..10de6c3e0f 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.2.2", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,15 @@ "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/search" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/catalog-model": "^0.4.0", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@backstage/catalog-model": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.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", @@ -34,11 +43,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", 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/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx index 7bd344e12e..888a583547 100644 --- a/plugins/search/src/components/Filters/Filters.tsx +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -103,7 +103,7 @@ export const Filters = ({ > {filterOptions.kind.map(filter => ( ({ 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[] = [ { @@ -127,7 +120,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { const [showFilters, toggleFilters] = useState(false); const [selectedFilters, setSelectedFilters] = useState({ - selected: 'All', + selected: '', checked: [], }); @@ -146,7 +139,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { // apply filters // filter on selected - if (selectedFilters.selected !== 'All') { + if (selectedFilters.selected !== '') { withFilters = results.filter((result: Result) => selectedFilters.selected.includes(result.kind), ); @@ -192,7 +185,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { const resetFilters = () => { setSelectedFilters({ - selected: 'All', + selected: '', checked: [], }); }; @@ -251,7 +244,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { )}
{ searchQuery={searchQuery} numberOfResults={filteredResults.length} numberOfSelectedFilters={ - (selectedFilters.selected !== 'All' ? 1 : 0) + + (selectedFilters.selected !== '' ? 1 : 0) + selectedFilters.checked.length } handleToggleFilters={() => toggleFilters(!showFilters)} 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-backend/CHANGELOG.md b/plugins/sentry-backend/CHANGELOG.md deleted file mode 100644 index 9ca0a5415e..0000000000 --- a/plugins/sentry-backend/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# @backstage/plugin-sentry-backend - -## 0.1.3 - -### Patch Changes - -- Updated dependencies [1722cb53c] -- Updated dependencies [1722cb53c] -- Updated dependencies [7b37e6834] -- Updated dependencies [8e2effb53] - - @backstage/backend-common@0.3.0 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [5249594c5] -- Updated dependencies [56e4eb589] -- Updated dependencies [e37c0a005] -- Updated dependencies [f00ca3cb8] -- Updated dependencies [6579769df] -- Updated dependencies [8c2b76e45] -- Updated dependencies [440a17b39] -- Updated dependencies [8afce088a] -- Updated dependencies [7bbeb049f] - - @backstage/backend-common@0.2.0 diff --git a/plugins/sentry-backend/README.md b/plugins/sentry-backend/README.md deleted file mode 100644 index efd4c1b472..0000000000 --- a/plugins/sentry-backend/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# sentry-backend - -Simple plugin forwarding requests to [Sentry](https://sentry.io) API. diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json deleted file mode 100644 index 5f5eafd5c6..0000000000 --- a/plugins/sentry-backend/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@backstage/plugin-sentry-backend", - "version": "0.1.3", - "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" - }, - "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.3.3", - "@types/express": "^4.17.6", - "axios": "^0.20.0", - "compression": "^1.7.4", - "cors": "^2.8.5", - "express": "^4.17.1", - "express-promise-router": "^3.0.3", - "fs-extra": "^9.0.0", - "helmet": "^4.0.0", - "morgan": "^1.10.0", - "winston": "^3.2.1", - "yn": "^4.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.4.0" - }, - "files": [ - "dist" - ] -} diff --git a/plugins/sentry-backend/src/service/router.ts b/plugins/sentry-backend/src/service/router.ts deleted file mode 100644 index 153a8325b5..0000000000 --- a/plugins/sentry-backend/src/service/router.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 { Logger } from 'winston'; -import Router from 'express-promise-router'; -import express from 'express'; -import { getSentryApiForwarder } from './sentry-api'; - -export async function createRouter(logger: Logger): Promise { - const router = Router(); - router.use(express.json()); - - const SENTRY_TOKEN = process.env.SENTRY_TOKEN; - if (!SENTRY_TOKEN) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.', - ); - } - logger.warn( - 'Failed to initialize Sentry backend, set SENTRY_TOKEN environment variable to start the API.', - ); - } else { - const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger); - - router.use(sentryForwarder); - } - - return router; -} diff --git a/plugins/sentry-backend/src/service/sentry-api.ts b/plugins/sentry-backend/src/service/sentry-api.ts deleted file mode 100644 index 8d35038840..0000000000 --- a/plugins/sentry-backend/src/service/sentry-api.ts +++ /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 express from 'express'; -import axios from 'axios'; -import { Logger } from 'winston'; - -export function getRequestHeaders(token: string) { - return { - headers: { - Authorization: `Bearer ${token}`, - }, - }; -} - -export function getSentryApiForwarder(token: string, logger: Logger) { - return function forwardRequest( - request: express.Request, - response: express.Response, - ) { - const sentryUrl = request.path; - const effectiveUrl = `https://sentry.io/${sentryUrl}`; - logger.info(`Calling Sentry REST API, ${effectiveUrl}`); - axios - .get(effectiveUrl, getRequestHeaders(token)) - .then(res => { - response.send(res.data); - }) - .catch(err => { - return response.status(err.response.status).json({ - detail: err.response.statusText, - }); - }); - }; -} diff --git a/plugins/sentry-backend/src/service/standaloneApplication.ts b/plugins/sentry-backend/src/service/standaloneApplication.ts deleted file mode 100644 index fdaad8bb2e..0000000000 --- a/plugins/sentry-backend/src/service/standaloneApplication.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 { - errorHandler, - notFoundHandler, - requestLoggingHandler, -} from '@backstage/backend-common'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import { Logger } from 'winston'; -import { createRouter } from './router'; - -export async function createStandaloneApplication( - logger: Logger, -): Promise { - const app = express(); - - app.use(helmet()); - app.use(cors()); - app.use(express.json()); - app.use(requestLoggingHandler()); - app.use('/', await createRouter(logger)); - app.use(notFoundHandler()); - app.use(errorHandler()); - - return app; -} diff --git a/plugins/sentry-backend/src/service/standaloneServer.ts b/plugins/sentry-backend/src/service/standaloneServer.ts deleted file mode 100644 index 37b87c5c40..0000000000 --- a/plugins/sentry-backend/src/service/standaloneServer.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 { Server } from 'http'; -import { Logger } from 'winston'; -import { createStandaloneApplication } from './standaloneApplication'; - -export async function startStandaloneServer( - parentLogger: Logger, -): Promise { - const logger = parentLogger.child({ service: 'scaffolder-backend' }); - logger.debug('Creating application...'); - - const app = await createStandaloneApplication(logger); - - logger.debug('Starting application server...'); - const PORT = parseInt(process.env.PORT || '5001', 10); - return await new Promise((resolve, reject) => { - const server = app.listen(PORT, (err?: Error) => { - if (err) { - reject(err); - return; - } - - logger.info(`Listening on port ${PORT}`); - resolve(server); - }); - }); -} diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 026c0e80c7..2902faab77 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,186 @@ # @backstage/plugin-sentry +## 0.3.7 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.3.6 + +### Patch Changes + +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.3.5 + +### Patch Changes + +- 53d3e2d62: Export the plugin instance as `sentryPlugin`. The plugin instance is still exported as `plugin` as well, but it will be removed in the future. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 962d1ad66: Added configuration schema for the commonly used properties +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + +## 0.3.0 + +### Minor Changes + +- 075d3dc5a: The plugin uses the `proxy-backend` instead of a custom `sentry-backend`. + It requires a proxy configuration: + + `app-config.yaml`: + + ```yaml + proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN # export SENTRY_TOKEN="Bearer " + ``` + + The `MockApiBackend` is no longer configured by the `NODE_ENV` variable. + Instead, the mock backend can be used with an api-override: + + `packages/app/src/apis.ts`: + + ```ts + import { createApiFactory } from '@backstage/core'; + import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + + export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), + ]; + ``` + + If you already use the Sentry backend, you must remove it from the backend: + + Delete `packages/backend/src/plugins/sentry.ts`. + + ```diff + # packages/backend/package.json + + ... + "@backstage/plugin-scaffolder-backend": "^0.3.2", + - "@backstage/plugin-sentry-backend": "^0.1.3", + "@backstage/plugin-techdocs-backend": "^0.3.0", + ... + ``` + + ```diff + // packages/backend/src/index.html + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/rollbar', await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + - apiRouter.use('/sentry', await sentry(sentryEnv)); + apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', await proxy(proxyEnv)); + apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use(notFoundHandler()); + ``` + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.2.4 ### Patch Changes diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 73cca29d34..5d280de249 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -1,17 +1,116 @@ -# sentry +# Sentry Plugin -Welcome to the sentry plugin! +The Sentry Plugin displays issues from [Sentry](https://sentry.io). -_This plugin was created through the Backstage CLI_ +![Sentry Card](./docs/sentry-card.png) -## Getting started +## 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 [/sentry](http://localhost:3000/sentry). +1. Install the Sentry Plugin: -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. +```bash +# packages/app -Needs SENTRY_TOKEN set in the environment for the backend to startup +yarn add @backstage/plugin-sentry +``` -export SENTRY_TOKEN= +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as Sentry } from '@backstage/plugin-sentry'; +``` + +3. Add the `SentryIssuesWidget` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { SentryIssuesWidget } from '@backstage/plugin-sentry'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +> You can also import a `Router` if you want to have a dedicated sentry page: +> +> ```tsx +> // packages/app/src/components/catalog/EntityPage.tsx +> +> import { Router as SentryRouter } from '@backstage/plugin-sentry'; +> +> const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( +> +> // ... +> path="/sentry" +> title="Sentry" +> element={} +> /> +> // ... +> +> ); +> ``` + +4. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Bearer ' + $env: SENTRY_TOKEN + +sentry: + organization: +``` + +5. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. + +6. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + sentry.io/project-slug: YOUR_PROJECT_SLUG +spec: + type: library + owner: CNCF + lifecycle: experimental +``` + +### Demo Mode + +The plugin provides a MockAPI that always returns dummy data instead of talking to the sentry backend. +You can add it by overriding the `sentryApiRef`: + +```ts +// packages/app/src/apis.ts + +import { createApiFactory } from '@backstage/core'; +import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + +export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), +]; +``` diff --git a/plugins/sentry/config.d.ts b/plugins/sentry/config.d.ts new file mode 100644 index 0000000000..4718568236 --- /dev/null +++ b/plugins/sentry/config.d.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 interface Config { + /** Configuration options for the sentry plugin */ + sentry: { + /** + * The 'organization' attribute + * @visibility frontend + */ + organization: string; + }; +} diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index 812a5585d4..87e03605e4 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -14,7 +14,94 @@ * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { Entity } from '@backstage/catalog-model'; +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, +} from '../src'; +import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; -createDevApp().registerPlugin(plugin).render(); +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SENTRY_PROJECT_SLUG_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + +createDevApp() + .registerApi({ + api: sentryApiRef, + deps: {}, + factory: () => + ({ + fetchIssues: async (project: string) => { + switch (project) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'with-values': + return new MockSentryApi().fetchIssues(); + + default: + return []; + } + }, + } as SentryApi), + }) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + ), + }) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + ), + }) + .render(); diff --git a/plugins/sentry/docs/sentry-card.png b/plugins/sentry/docs/sentry-card.png new file mode 100644 index 0000000000..29fefe32e2 Binary files /dev/null and b/plugins/sentry/docs/sentry-card.png differ diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index b952a81d51..3bc974bc6c 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.2.4", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/sentry" + }, + "keywords": [ + "backstage", + "sentry" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,13 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", - "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -36,34 +46,21 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/sentry", - "type": "object", - "properties": { - "sentry": { - "type": "object", - "properties": { - "organization": { - "type": "string", - "visibility": "frontend" - } - } - } - } - } + "configSchema": "config.d.ts" } diff --git a/plugins/sentry/src/api/index.ts b/plugins/sentry/src/api/index.ts new file mode 100644 index 0000000000..4cccfdb7a1 --- /dev/null +++ b/plugins/sentry/src/api/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 * from './mock'; +export type { SentryApi } from './sentry-api'; +export { sentryApiRef } from './sentry-api'; +export type { SentryIssue } from './sentry-issue'; +export { ProductionSentryApi } from './production-api'; diff --git a/plugins/api-docs/src/components/EntityLink/index.ts b/plugins/sentry/src/api/mock/index.ts similarity index 93% rename from plugins/api-docs/src/components/EntityLink/index.ts rename to plugins/sentry/src/api/mock/index.ts index 44875e4634..b65fb7a919 100644 --- a/plugins/api-docs/src/components/EntityLink/index.ts +++ b/plugins/sentry/src/api/mock/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { EntityLink } from './EntityLink'; +export { MockSentryApi } from './mock-api'; diff --git a/plugins/sentry/src/data/mock-api.ts b/plugins/sentry/src/api/mock/mock-api.ts similarity index 93% rename from plugins/sentry/src/data/mock-api.ts rename to plugins/sentry/src/api/mock/mock-api.ts index 8026fec4ee..6743cee79f 100644 --- a/plugins/sentry/src/data/mock-api.ts +++ b/plugins/sentry/src/api/mock/mock-api.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryIssue } from './sentry-issue'; -import { SentryApi } from './sentry-api'; + +import { SentryIssue } from '../sentry-issue'; +import { SentryApi } from '../sentry-api'; import mockData from './sentry-issue-mock.json'; function getMockIssue(): SentryIssue { diff --git a/plugins/sentry/src/data/sentry-issue-mock.json b/plugins/sentry/src/api/mock/sentry-issue-mock.json similarity index 100% rename from plugins/sentry/src/data/sentry-issue-mock.json rename to plugins/sentry/src/api/mock/sentry-issue-mock.json diff --git a/plugins/sentry/src/data/production-api.ts b/plugins/sentry/src/api/production-api.ts similarity index 53% rename from plugins/sentry/src/data/production-api.ts rename to plugins/sentry/src/api/production-api.ts index 5d21e80604..bf6fa98bce 100644 --- a/plugins/sentry/src/data/production-api.ts +++ b/plugins/sentry/src/api/production-api.ts @@ -13,36 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { SentryIssue } from './sentry-issue'; import { SentryApi } from './sentry-api'; +import { DiscoveryApi } from '@backstage/core'; export class ProductionSentryApi implements SentryApi { - private organization: string; - private backendBaseUrl: string; - - constructor(organization: string, backendBaseUrl: string) { - this.organization = organization; - this.backendBaseUrl = backendBaseUrl; - } + constructor( + private readonly discoveryApi: DiscoveryApi, + private readonly organization: string, + ) {} async fetchIssues(project: string, statsFor: string): Promise { - try { - const apiBaseUrl = `${this.backendBaseUrl}/sentry/api/0/projects/`; - - const response = await fetch( - `${apiBaseUrl}/${this.organization}/${project}/issues/?statsFor=${statsFor}`, - ); - - if (response.status >= 400 && response.status < 600) { - throw new Error('Failed fetching Sentry issues'); - } - - return (await response.json()) as SentryIssue[]; - } catch (exception) { - if (exception.detail) { - return exception; - } - throw new Error('Unknown error'); + if (!project) { + return []; } + + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sentry/api`; + + const response = await fetch( + `${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsFor=${statsFor}`, + ); + + if (response.status >= 400 && response.status < 600) { + throw new Error('Failed fetching Sentry issues'); + } + + return (await response.json()) as SentryIssue[]; } } diff --git a/plugins/sentry/src/api/sentry-api.ts b/plugins/sentry/src/api/sentry-api.ts new file mode 100644 index 0000000000..1edb550ec4 --- /dev/null +++ b/plugins/sentry/src/api/sentry-api.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SentryIssue } from './sentry-issue'; +import { createApiRef } from '@backstage/core'; + +export const sentryApiRef = createApiRef({ + id: 'plugin.sentry.service', + description: 'Used by the Sentry plugin to make requests', +}); + +export interface SentryApi { + fetchIssues(project: string, statsFor: string): Promise; +} diff --git a/plugins/sentry/src/data/sentry-issue.ts b/plugins/sentry/src/api/sentry-issue.ts similarity index 99% rename from plugins/sentry/src/data/sentry-issue.ts rename to plugins/sentry/src/api/sentry-issue.ts index 14621bf629..017396229d 100644 --- a/plugins/sentry/src/data/sentry-issue.ts +++ b/plugins/sentry/src/api/sentry-issue.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + type SentryPlatform = 'javascript' | 'javascript-react' | string; type EventPoint = number[]; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx index 74f1501225..b02c2860ad 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ErrorCell } from './ErrorCell'; import React from 'react'; import { render } from '@testing-library/react'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx index 617bdb3ce9..8834c12f06 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; + +import React from 'react'; +import { SentryIssue } from '../../api'; import { Link, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; @@ -41,9 +42,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const ErrorCell: FC<{ sentryIssue: SentryIssue }> = ({ - sentryIssue, -}) => { +export const ErrorCell = ({ sentryIssue }: { sentryIssue: SentryIssue }) => { const classes = useStyles(); return (
diff --git a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx index 7643c5ac6e..f7e2049455 100644 --- a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx +++ b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; + +import React from 'react'; +import { SentryIssue } from '../../api'; import { Sparklines, SparklinesBars } from 'react-sparklines'; -export const ErrorGraph: FC<{ sentryIssue: SentryIssue }> = ({ - sentryIssue, -}) => { +export const ErrorGraph = ({ sentryIssue }: { sentryIssue: SentryIssue }) => { const data = '12h' in sentryIssue.stats ? sentryIssue.stats['12h'] diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx index 6d15268629..7bed3700ab 100644 --- a/plugins/sentry/src/components/Router.tsx +++ b/plugins/sentry/src/components/Router.tsx @@ -13,28 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { Routes, Route } from 'react-router'; -import { MissingAnnotationEmptyState } from '@backstage/core'; -import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; - -const SENTRY_ANNOTATION = 'sentry.io/project-slug'; +import { Route, Routes } from 'react-router'; +import { SentryIssuesWidget } from './SentryIssuesWidget'; export const Router = ({ entity }: { entity: Entity }) => { - const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; - - if (!projectId) { - return ; - } - return ( - } + element={} /> ) diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx index 6926fdfc2a..441104c0ee 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx @@ -13,11 +13,12 @@ * 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 SentryIssuesTable from './SentryIssuesTable'; -import { SentryIssue } from '../../data/sentry-issue'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import { SentryIssue } from '../../api'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index aae8009d98..72081bf2da 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Table, TableColumn } from '@backstage/core'; -import { SentryIssue } from '../../data/sentry-issue'; +import { SentryIssue } from '../../api'; import { format } from 'timeago.js'; import { ErrorCell } from '../ErrorCell/ErrorCell'; import { ErrorGraph } from '../ErrorGraph/ErrorGraph'; diff --git a/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx new file mode 100644 index 0000000000..b1ba8c7cc2 --- /dev/null +++ b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.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 { Entity } from '@backstage/catalog-model'; +import { + EmptyState, + ErrorApi, + errorApiRef, + InfoCard, + InfoCardVariants, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import React, { useEffect } from 'react'; +import { useAsync } from 'react-use'; +import { sentryApiRef } from '../../api'; +import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; +import { + SENTRY_PROJECT_SLUG_ANNOTATION, + useProjectSlug, +} from '../useProjectSlug'; + +export const SentryIssuesWidget = ({ + entity, + statsFor = '24h', + variant = 'gridItem', +}: { + entity: Entity; + statsFor?: '24h' | '12h'; + variant?: InfoCardVariants; +}) => { + const errorApi = useApi(errorApiRef); + const sentryApi = useApi(sentryApiRef); + + const projectId = useProjectSlug(entity); + + const { loading, value, error } = useAsync( + () => sentryApi.fetchIssues(projectId, statsFor), + [sentryApi, statsFor, projectId], + ); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + if (loading || !projectId || error) { + return ( + + {loading && } + + {!loading && !projectId && ( + + )} + + {!loading && error && ( + + )} + + ); + } + + return ; +}; diff --git a/plugins/sentry/src/components/SentryIssuesWidget/index.ts b/plugins/sentry/src/components/SentryIssuesWidget/index.ts new file mode 100644 index 0000000000..fddc1374f2 --- /dev/null +++ b/plugins/sentry/src/components/SentryIssuesWidget/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 { SentryIssuesWidget } from './SentryIssuesWidget'; diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx deleted file mode 100644 index 21c21ce385..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx +++ /dev/null @@ -1,58 +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 SentryPluginPage from './SentryPluginPage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; -import { msw } from '@backstage/test-utils'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; - -import { - ApiProvider, - ApiRegistry, - errorApiRef, - configApiRef, -} from '@backstage/core'; - -const errorApi = { post: () => {} }; -const ConfigApi = { getString: () => 'test' }; - -describe('SentryPluginPage', () => { - const server = setupServer(); - msw.setupDefaultHandlers(server); - - it('should render header and time switched', () => { - server.use(rest.get('/', (_req, res, ctx) => res(ctx.json({})))); - const rendered = render( - - - - - , - ); - expect(rendered.getByText('Sentry issues')).toBeInTheDocument(); - expect(rendered.getByText('24H')).toBeInTheDocument(); - expect(rendered.getByText('12H')).toBeInTheDocument(); - }); -}); diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx deleted file mode 100644 index 763a8d753f..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx +++ /dev/null @@ -1,69 +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, { FC, useState } from 'react'; -import { Grid } from '@material-ui/core'; -import { - Header, - Page, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { SentryPluginWidget } from '../SentryPluginWidget/SentryPluginWidget'; -import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab'; - -const SentryPluginPage: FC<{}> = () => { - const [statsFor, setStatsFor] = useState<'12h' | '24h'>('12h'); - const toggleStatsFor = () => setStatsFor(statsFor === '12h' ? '12h' : '24h'); - const sentryProjectId = 'sample-sentry-project-id'; - - return ( - -
- - - - - 24H - - - 12H - - - - Sentry plugin allows you to preview issues and navigate to sentry. - - - - - - - - - - ); -}; - -export default SentryPluginPage; diff --git a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx b/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx deleted file mode 100644 index 8d522df4a7..0000000000 --- a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx +++ /dev/null @@ -1,60 +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, { FC, useEffect } from 'react'; -import { - ErrorApi, - errorApiRef, - InfoCard, - Progress, - useApi, - configApiRef, -} from '@backstage/core'; -import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; -import { useAsync } from 'react-use'; -import { sentryApiFactory } from '../../data/api-factory'; - -export const SentryPluginWidget: FC<{ - sentryProjectId: string; - statsFor: '24h' | '12h'; -}> = ({ sentryProjectId, statsFor }) => { - const errorApi = useApi(errorApiRef); - const configApi = useApi(configApiRef); - const org = configApi.getString('sentry.organization'); - const backendBaseUrl = configApi.getString('backend.baseUrl'); - const api = sentryApiFactory(org, backendBaseUrl); - - const { loading, value, error } = useAsync( - () => api.fetchIssues(sentryProjectId, statsFor), - [statsFor, sentryProjectId], - ); - - useEffect(() => { - if (error) { - errorApi.post(error); - } - }, [error, errorApi]); - - if (loading) { - return ( - - - - ); - } - - return ; -}; diff --git a/plugins/sentry/src/components/index.ts b/plugins/sentry/src/components/index.ts new file mode 100644 index 0000000000..b1588954c9 --- /dev/null +++ b/plugins/sentry/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 './SentryIssuesWidget'; diff --git a/plugins/catalog-import/src/util/validate.ts b/plugins/sentry/src/components/useProjectSlug.ts similarity index 70% rename from plugins/catalog-import/src/util/validate.ts rename to plugins/sentry/src/components/useProjectSlug.ts index 056131aaae..072d517b05 100644 --- a/plugins/catalog-import/src/util/validate.ts +++ b/plugins/sentry/src/components/useProjectSlug.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -export const ComponentIdValidators = { - httpsValidator: (value: any) => - (typeof value === 'string' && value.match(/^https:\/\//) !== null) || - 'Must start with https://.', +import { Entity } from '@backstage/catalog-model'; + +export const SENTRY_PROJECT_SLUG_ANNOTATION = 'sentry.io/project-slug'; + +export const useProjectSlug = (entity: Entity) => { + return entity?.metadata.annotations?.[SENTRY_PROJECT_SLUG_ANNOTATION] ?? ''; }; diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx new file mode 100644 index 0000000000..9a16a6a7da --- /dev/null +++ b/plugins/sentry/src/extensions.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createComponentExtension, + createRoutableExtension, +} from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { sentryPlugin, rootRouteRef } from './plugin'; + +export const EntitySentryContent = sentryPlugin.provide( + createRoutableExtension({ + mountPoint: rootRouteRef, + component: () => + import('./components/SentryIssuesWidget').then( + ({ SentryIssuesWidget }) => { + const SentryPage = () => { + const { entity } = useEntity(); + return ; + }; + return SentryPage; + }, + ), + }), +); + +export const EntitySentryCard = sentryPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SentryIssuesWidget').then( + ({ SentryIssuesWidget }) => { + const SentryCard = () => { + const { entity } = useEntity(); + return ; + }; + return SentryCard; + }, + ), + }, + }), +); diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index a0d3cab1be..2fc51715f6 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export * from './api'; +export * from './components'; +export { sentryPlugin, sentryPlugin as plugin } from './plugin'; +export { EntitySentryCard, EntitySentryContent } from './extensions'; export { Router } from './components/Router'; -export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget'; diff --git a/plugins/sentry/src/plugin.test.ts b/plugins/sentry/src/plugin.test.ts index 8f24236586..af5feaa774 100644 --- a/plugins/sentry/src/plugin.test.ts +++ b/plugins/sentry/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { sentryPlugin } from './plugin'; describe('sentry', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(sentryPlugin).toBeDefined(); }); }); diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index fc4b1af5e0..4c2a00de59 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -14,17 +14,34 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import SentryPluginPage from './components/SentryPluginPage'; +import { + configApiRef, + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { ProductionSentryApi, sentryApiRef } from './api'; export const rootRouteRef = createRouteRef({ path: '/sentry', title: 'Sentry', }); -export const plugin = createPlugin({ +export const sentryPlugin = createPlugin({ id: 'sentry', - register({ router }) { - router.addRoute(rootRouteRef, SentryPluginPage); + apis: [ + createApiFactory({ + api: sentryApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new ProductionSentryApi( + discoveryApi, + configApi.getString('sentry.organization'), + ), + }), + ], + routes: { + root: rootRouteRef, }, }); diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 69d94fa655..677cdcbb32 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,117 @@ # @backstage/plugin-sonarqube +## 0.1.13 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.1.12 + +### Patch Changes + +- 3a82293da: Fix bug retrieving current theme +- f4c2bcf54: Use a more strict type for `variant` of cards. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.1.11 + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + +## 0.1.6 + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.1.5 ### 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 812a5585d4..2dd9236547 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -14,7 +14,144 @@ * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { Entity } from '@backstage/catalog-model'; +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 { EntitySonarQubeCard, sonarQubePlugin } from '../src'; +import { FindingSummary, SonarQubeApi, sonarQubeApiRef } from '../src/api'; +import { SONARQUBE_PROJECT_KEY_ANNOTATION } from '../src/components/useProjectKey'; -createDevApp().registerPlugin(plugin).render(); +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: {}, + factory: () => + ({ + getFindingSummary: async componentKey => { + switch (componentKey) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'not-computed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + bugs: '0', + reliability_rating: '1.0', + vulnerabilities: '0', + security_rating: '1.0', + code_smells: '0', + sqale_rating: '1.0', + coverage: '0.0', + duplicated_lines_density: '0.0', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, + } as FindingSummary; + + case 'failed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + alert_status: 'FAILED', + bugs: '4', + 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', + duplicated_lines_density: '15.6', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, + } as FindingSummary; + + case 'passed': + return { + lastAnalysis: new Date().toISOString(), + metrics: { + alert_status: 'OK', + bugs: '0', + 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', + duplicated_lines_density: '0.0', + }, + projectUrl: `/#${componentKey}`, + getIssuesUrl: i => `/#${componentKey}/issues/${i}`, + getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, + } as FindingSummary; + + default: + return undefined; + } + }, + } as SonarQubeApi), + }) + .render(); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 2dd52ab021..88e9e97c06 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.5", + "version": "0.1.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,17 @@ "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/sonarqube" + }, + "keywords": [ + "backstage", + "sonarqube", + "sonarcloud" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,9 +32,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/catalog-model": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/core": "^0.6.3", + "@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", @@ -35,11 +47,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts new file mode 100644 index 0000000000..0715d1fd02 --- /dev/null +++ b/plugins/sonarqube/src/api/SonarQubeApi.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 { createApiRef } from '@backstage/core'; +import { MetricKey, SonarUrlProcessorFunc } from './types'; + +/** + * Define a type to make sure that all metrics are used + */ +export type Metrics = { + [key in MetricKey]: string | undefined; +}; + +export interface FindingSummary { + lastAnalysis: string; + metrics: Metrics; + projectUrl: string; + getIssuesUrl: SonarUrlProcessorFunc; + getComponentMeasuresUrl: SonarUrlProcessorFunc; + getSecurityHotspotsUrl: () => string; +} + +export const sonarQubeApiRef = createApiRef({ + id: 'plugin.sonarqube.service', + description: 'Used by the SonarQube plugin to make requests', +}); + +export type SonarQubeApi = { + getFindingSummary(componentKey?: string): Promise; +}; diff --git a/plugins/sonarqube/src/api/index.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts similarity index 64% rename from plugins/sonarqube/src/api/index.test.ts rename to plugins/sonarqube/src/api/SonarQubeClient.test.ts index 2d96fc2135..3d488aa9a5 100644 --- a/plugins/sonarqube/src/api/index.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -18,18 +18,57 @@ import { UrlPatternDiscovery } from '@backstage/core'; import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { FindingSummary, SonarQubeApi } from './index'; +import { FindingSummary, SonarQubeClient } from './index'; import { ComponentWrapper, MeasuresWrapper } from './types'; const server = setupServer(); -describe('SonarQubeApi', () => { +describe('SonarQubeClient', () => { msw.setupDefaultHandlers(server); 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`, (req, res, ctx) => { + expect(req.url.searchParams.get('ps')).toBe('500'); + + // emulate paging to check if everything is requested + if (req.url.searchParams.get('p') === '1') { + return res( + ctx.json({ + metrics: metricKeys.slice(0, 5).map(k => ({ key: k })), + total: metricKeys.length, + }), + ); + } + + // make sure this is only called twice + expect(req.url.searchParams.get('p')).toBe('2'); + return res( + ctx.json({ + metrics: metricKeys.slice(5).map(k => ({ key: k })), + total: metricKeys.length, + }), + ); + }), + ); + server.use( rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe('component=our-service'); @@ -46,8 +85,9 @@ describe('SonarQubeApi', () => { 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 +121,16 @@ describe('SonarQubeApi', () => { 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 +151,7 @@ describe('SonarQubeApi', () => { value: '1.0', component: 'our-service', }, - ], + ].filter(m => metricKeys.includes(m.metric)), } as MeasuresWrapper), ); }), @@ -111,7 +161,7 @@ describe('SonarQubeApi', () => { it('should report finding summary', async () => { setupHandlers(); - const client = new SonarQubeApi({ discoveryApi }); + const client = new SonarQubeClient({ discoveryApi }); const summary = await client.getFindingSummary('our-service'); expect(summary).toEqual( @@ -123,6 +173,8 @@ describe('SonarQubeApi', () => { 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', @@ -142,7 +194,7 @@ describe('SonarQubeApi', () => { it('should report finding summary (custom baseUrl)', async () => { setupHandlers(); - const client = new SonarQubeApi({ + const client = new SonarQubeClient({ discoveryApi, baseUrl: 'http://a.instance.local', }); @@ -158,6 +210,8 @@ describe('SonarQubeApi', () => { 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 +227,32 @@ describe('SonarQubeApi', () => { '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 new file mode 100644 index 0000000000..4f149d8d1f --- /dev/null +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi'; +import { ComponentWrapper, MeasuresWrapper } from './types'; + +export class SonarQubeClient implements SonarQubeApi { + discoveryApi: DiscoveryApi; + baseUrl: string; + + constructor({ + discoveryApi, + baseUrl = 'https://sonarcloud.io/', + }: { + discoveryApi: DiscoveryApi; + baseUrl?: string; + }) { + this.discoveryApi = discoveryApi; + this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + } + + private async callApi( + path: string, + query: { [key in string]: any }, + ): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`; + const response = await fetch( + `${apiUrl}/${path}?${new URLSearchParams(query).toString()}`, + ); + if (response.status === 200) { + return (await response.json()) as T; + } + return undefined; + } + + private async getSupportedMetrics(): Promise { + const metrics: string[] = []; + let nextPage: number = 1; + + for (;;) { + const result = await this.callApi<{ + metrics: Array<{ key: string }>; + total: number; + }>('metrics/search', { ps: 500, p: nextPage }); + + metrics.push(...(result?.metrics?.map(m => m.key) ?? [])); + + if (result && metrics.length < result.total) { + nextPage++; + continue; + } + + return metrics; + } + } + + async getFindingSummary( + componentKey?: string, + ): Promise { + if (!componentKey) { + return undefined; + } + + const component = await this.callApi('components/show', { + component: componentKey, + }); + if (!component) { + return undefined; + } + + const metrics: Metrics = { + alert_status: undefined, + bugs: undefined, + reliability_rating: undefined, + vulnerabilities: undefined, + security_rating: undefined, + 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: metricKeys.join(','), + }); + if (!measures) { + return undefined; + } + + measures.measures + .filter(m => m.component === componentKey) + .forEach(m => { + metrics[m.metric] = m.value; + }); + + return { + lastAnalysis: component.component.analysisDate, + metrics, + projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`, + getIssuesUrl: identifier => + `${ + this.baseUrl + }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, + getComponentMeasuresUrl: identifier => + `${ + 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/index.ts b/plugins/sonarqube/src/api/index.ts index f2c616f53c..8442465dee 100644 --- a/plugins/sonarqube/src/api/index.ts +++ b/plugins/sonarqube/src/api/index.ts @@ -14,112 +14,6 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; -import fetch from 'cross-fetch'; -import { - ComponentWrapper, - MeasuresWrapper, - MetricKey, - SonarUrlProcessorFunc, -} from './types'; - -/** - * Define a type to make sure that all metrics are used - */ -type Metrics = { - [key in MetricKey]: string | undefined; -}; - -export interface FindingSummary { - lastAnalysis: string; - metrics: Metrics; - projectUrl: string; - getIssuesUrl: SonarUrlProcessorFunc; - getComponentMeasuresUrl: SonarUrlProcessorFunc; -} - -export const sonarQubeApiRef = createApiRef({ - id: 'plugin.sonarqube.service', - description: 'Used by the SonarQube plugin to make requests', -}); - -export class SonarQubeApi { - discoveryApi: DiscoveryApi; - baseUrl: string; - - constructor({ - discoveryApi, - baseUrl = 'https://sonarcloud.io/', - }: { - discoveryApi: DiscoveryApi; - baseUrl?: string; - }) { - this.discoveryApi = discoveryApi; - this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - } - - private async callApi(path: string): Promise { - const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`; - const response = await fetch(`${apiUrl}/${path}`); - if (response.status === 200) { - return (await response.json()) as T; - } - return undefined; - } - - async getFindingSummary( - componentKey?: string, - ): Promise { - if (!componentKey) { - return undefined; - } - - const component = await this.callApi( - `components/show?component=${componentKey}`, - ); - if (!component) { - return undefined; - } - - const metrics: Metrics = { - alert_status: undefined, - bugs: undefined, - reliability_rating: undefined, - vulnerabilities: undefined, - security_rating: undefined, - code_smells: undefined, - sqale_rating: undefined, - coverage: undefined, - duplicated_lines_density: undefined, - }; - - const measures = await this.callApi( - `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys( - metrics, - ).join(',')}`, - ); - if (!measures) { - return undefined; - } - - measures.measures - .filter(m => m.component === componentKey) - .forEach(m => { - metrics[m.metric] = m.value; - }); - - return { - lastAnalysis: component.component.analysisDate, - metrics, - projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`, - getIssuesUrl: identifier => - `${ - this.baseUrl - }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, - getComponentMeasuresUrl: (identifier: string) => - `${ - this.baseUrl - }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, - }; - } -} +export type { Metrics, FindingSummary, SonarQubeApi } from './SonarQubeApi'; +export { sonarQubeApiRef } from './SonarQubeApi'; +export { SonarQubeClient } from './SonarQubeClient'; 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/Percentage.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx index 9d1fc415ff..838e3d6609 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx @@ -16,7 +16,7 @@ import { BackstageTheme } from '@backstage/theme'; import { makeStyles } from '@material-ui/core/styles'; -import { useTheme } from '@material-ui/styles'; +import { useTheme } from '@material-ui/core'; import { Circle } from 'rc-progress'; import React from 'react'; 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..14b8ae1af6 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -18,14 +18,17 @@ import { Entity } from '@backstage/catalog-model'; import { EmptyState, InfoCard, + InfoCardVariants, MissingAnnotationEmptyState, 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 +71,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 +85,14 @@ const defaultDuplicationRatings: DuplicationRating[] = [ ]; export const SonarQubeCard = ({ - entity, variant = 'gridItem', duplicationRatings = defaultDuplicationRatings, }: { - entity: Entity; - variant?: string; + entity?: Entity; + variant?: InfoCardVariants; duplicationRatings?: DuplicationRating[]; }) => { + const { entity } = useEntity(); const sonarQubeApi = useApi(sonarQubeApiRef); const projectTitle = useProjectKey(entity); @@ -205,12 +208,39 @@ export const SonarQubeCard = ({ leftSlot={} rightSlot={} /> + {value.metrics.security_review_rating && ( + } + title="Hotspots Reviewed" + link={value.getSecurityHotspotsUrl()} + leftSlot={ + + } + rightSlot={ + + } + /> + )}
} - rightSlot={} + 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 154d4ee3b8..3f58b24abf 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -17,22 +17,32 @@ import { configApiRef, createApiFactory, + createComponentExtension, createPlugin, discoveryApiRef, } from '@backstage/core'; -import { SonarQubeApi, sonarQubeApiRef } from './api'; +import { sonarQubeApiRef, SonarQubeClient } from './api'; -export const plugin = createPlugin({ +export const sonarQubePlugin = createPlugin({ id: 'sonarqube', apis: [ createApiFactory({ api: sonarQubeApiRef, deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, factory: ({ configApi, discoveryApi }) => - new SonarQubeApi({ + new SonarQubeClient({ discoveryApi, baseUrl: configApi.getOptionalString('sonarQube.baseUrl'), }), }), ], }); + +export const EntitySonarQubeCard = sonarQubePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SonarQubeCard').then(m => m.SonarQubeCard), + }, + }), +); diff --git a/plugins/splunk-on-call/.eslintrc.js b/plugins/splunk-on-call/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/splunk-on-call/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/splunk-on-call/CHANGELOG.md b/plugins/splunk-on-call/CHANGELOG.md new file mode 100644 index 0000000000..bff3bcf8bf --- /dev/null +++ b/plugins/splunk-on-call/CHANGELOG.md @@ -0,0 +1,37 @@ +# @backstage/plugin-splunk-on-call + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + +## 0.1.2 + +### Patch Changes + +- 70e2ba9cf: Added splunk-on-call plugin. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/plugin-catalog-react@0.0.4 diff --git a/plugins/splunk-on-call/README.md b/plugins/splunk-on-call/README.md new file mode 100644 index 0000000000..73ee05c181 --- /dev/null +++ b/plugins/splunk-on-call/README.md @@ -0,0 +1,120 @@ +# Splunk On-Call + +## Overview + +This plugin displays Splunk On-Call, formerly VictorOps, information about an entity. + +There is a way to trigger an new incident directly to specific users or/and specific teams. + +This plugin requires that entities are annotated with a team name. See more further down in this document. + +This plugin provides: + +- A list of incidents +- A way to trigger a new incident to specific users or/and teams +- A way to acknowledge/resolve an incident +- Information details about the persons on-call + +## Setup instructions + +Install the plugin: + +```bash +yarn add @backstage/plugin-splunk-on-call +``` + +Add it to the app in `plugins.ts`: + +```ts +export { plugin as SplunkOnCall } from '@backstage/plugin-splunk-on-call'; +``` + +Add it to the `EntityPage.tsx`: + +```ts +import { + isPluginApplicableToEntity as isSplunkOnCallAvailable, + SplunkOnCallCard, +} from '@backstage/plugin-splunk-on-call'; +// ... +{ + isSplunkOnCallAvailable(entity) && ( + + + + ); +} +``` + +## Client configuration + +In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide the username of the user making the action. +The user supplied must be a valid Splunk On-Call user and a member of your organization. + +In `app-config.yaml`: + +```yaml +splunkOnCall: + username: +``` + +The user supplied must be a valid Splunk On-Call user and a member of your organization. + +In order to make the API calls, you need to provide a new proxy config which will redirect to the Splunk On-Call API endpoint and add authentication information in the headers: + +```yaml +# app-config.yaml +proxy: + # ... + '/splunk-on-call': + target: https://api.victorops.com/api-public + headers: + X-VO-Api-Id: + $env: SPLUNK_ON_CALL_API_ID + X-VO-Api-Key: + $env: SPLUNK_ON_CALL_API_KEY +``` + +In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`. + +### Adding your team name to the entity annotation + +The information displayed for each entity is based on the team name. +If you want to use this plugin for an entity, you need to label it with the below annotation: + +```yaml +annotations: + splunk.com/on-call-team': +``` + +## Providing the API key and API id + +In order for the client to make requests to the [Splunk On-Call API](https://portal.victorops.com/public/api-docs.html#/) it needs an [API ID and an API Key](https://help.victorops.com/knowledge-base/api/). + +Then start the backend passing the values as an environment variable: + +```bash +$ SPLUNK_ON_CALL_API_KEY='' SPLUNK_ON_CALL_API_ID='' yarn start +``` + +This will proxy the request by adding `X-VO-Api-Id` and `X-VO-Api-Key` headers with the provided values. + +You can also add the values in your helm template: + +```yaml +# backend-secret.yaml +stringData: + # ... + SPLUNK_ON_CALL_API_ID: { { .Values.auth.splunkOnCallApiId } } + SPLUNK_ON_CALL_API_KEY: { { .Values.auth.splunkOnCallApiKey } } +``` + +To enable it you need to provide them in the chart's values: + +```yaml +# values.yaml +auth: + # ... + splunkOnCallApiId: h + splunkOnCallApiKey: h +``` diff --git a/plugins/splunk-on-call/dev/index.tsx b/plugins/splunk-on-call/dev/index.tsx new file mode 100644 index 0000000000..346c92c37e --- /dev/null +++ b/plugins/splunk-on-call/dev/index.tsx @@ -0,0 +1,26 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { splunkOnCallPlugin, SplunkOnCallPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(splunkOnCallPlugin) + .addPage({ + title: 'Splunk On-Call', + element: , + }) + .render(); diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json new file mode 100644 index 0000000000..0d25e2b374 --- /dev/null +++ b/plugins/splunk-on-call/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/plugin-splunk-on-call", + "version": "0.1.3", + "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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/splunk-on-call" + }, + "keywords": [ + "backstage", + "splunk-on-call" + ], + "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.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.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", + "classnames": "^2.2.6", + "luxon": "^1.25.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.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/splunk-on-call/src/api/client.ts b/plugins/splunk-on-call/src/api/client.ts new file mode 100644 index 0000000000..501dad80a2 --- /dev/null +++ b/plugins/splunk-on-call/src/api/client.ts @@ -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 { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core'; +import { + Incident, + OnCall, + User, + EscalationPolicyInfo, + Team, +} from '../components/types'; +import { + SplunkOnCallApi, + TriggerAlarmRequest, + IncidentsResponse, + OnCallsResponse, + ClientApiConfig, + RequestOptions, + ListUserResponse, + EscalationPolicyResponse, + PatchIncidentRequest, +} from './types'; + +export class UnauthorizedError extends Error {} + +export const splunkOnCallApiRef = createApiRef({ + id: 'plugin.splunk-on-call.api', + description: 'Used to fetch data from Splunk On-Call API', +}); + +export class SplunkOnCallClient implements SplunkOnCallApi { + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const usernameFromConfig: string | null = + configApi.getOptionalString('splunkOnCall.username') || null; + return new SplunkOnCallClient({ + username: usernameFromConfig, + discoveryApi, + }); + } + constructor(private readonly config: ClientApiConfig) {} + + async getIncidents(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents`; + + const { incidents } = await this.getByUrl(url); + + return incidents; + } + + async getOnCallUsers(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/oncall/current`; + const { teamsOnCall } = await this.getByUrl(url); + + return teamsOnCall; + } + + async getTeams(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/team`; + const teams = await this.getByUrl(url); + + return teams; + } + + async acknowledgeIncident({ + incidentNames, + }: PatchIncidentRequest): Promise { + const options = { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userName: this.config.username, + incidentNames, + }), + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents/ack`; + + return this.request(url, options); + } + + async resolveIncident({ + incidentNames, + }: PatchIncidentRequest): Promise { + const options = { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userName: this.config.username, + incidentNames, + }), + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents/resolve`; + + return this.request(url, options); + } + + async getUsers(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v2/user`; + const { users } = await this.getByUrl(url); + + return users; + } + + async getEscalationPolicies(): Promise { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/policies`; + const { policies } = await this.getByUrl(url); + + return policies; + } + + async triggerAlarm({ + summary, + details, + userName, + targets, + isMultiResponder, + }: TriggerAlarmRequest): Promise { + const body = JSON.stringify({ + summary, + details, + userName: this.config.username || userName, + targets, + isMultiResponder, + }); + + const options = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }; + + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/incidents`; + + return this.request(url, options); + } + + private async getByUrl(url: string): Promise { + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }; + const response = await this.request(url, options); + + return response.json(); + } + + private async request( + url: string, + options: RequestOptions, + ): Promise { + const response = await fetch(url, options); + if (response.status === 403) { + throw new UnauthorizedError(); + } + if (!response.ok) { + const payload = await response.json(); + const errors = payload.errors.map((error: string) => error).join(' '); + const message = `Request failed with ${response.status}, ${errors}`; + throw new Error(message); + } + return response; + } +} diff --git a/plugins/splunk-on-call/src/api/index.ts b/plugins/splunk-on-call/src/api/index.ts new file mode 100644 index 0000000000..1e4056fbc0 --- /dev/null +++ b/plugins/splunk-on-call/src/api/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + SplunkOnCallClient, + splunkOnCallApiRef, + UnauthorizedError, +} from './client'; +export type { SplunkOnCallApi } from './types'; diff --git a/plugins/splunk-on-call/src/api/mocks.ts b/plugins/splunk-on-call/src/api/mocks.ts new file mode 100644 index 0000000000..86df04275d --- /dev/null +++ b/plugins/splunk-on-call/src/api/mocks.ts @@ -0,0 +1,100 @@ +/* + * 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 { + EscalationPolicyInfo, + Incident, + Team, + User, +} from '../components/types'; + +export const MOCKED_USER: User = { + createdAt: '2021-02-01T23:38:38Z', + displayName: 'Test User', + email: 'test@example.com', + firstName: 'FirstNameTest', + lastName: 'LastNameTest', + passwordLastUpdated: '2021-02-01T23:38:38Z', + username: 'test_user', + verified: true, + _selfUrl: '/api-public/v1/user/test_user', +}; + +export const MOCKED_ON_CALL = [ + { + team: { name: 'team_example', slug: 'team-zEalMCgwYSA0Lt40' }, + oncallNow: [ + { + escalationPolicy: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' }, + users: [{ onCalluser: { username: 'test_user' } }], + }, + ], + }, +]; + +export const MOCK_INCIDENT: Incident = { + alertCount: 1, + currentPhase: 'ACKED', + entityDisplayName: 'test-incident', + entityId: 'entityId', + entityState: 'CRITICAL', + entityType: 'SERVICE', + incidentNumber: '1', + lastAlertId: 'lastAlertId', + lastAlertTime: '2021-02-03T00:13:11Z', + routingKey: 'routingdefault', + service: 'test', + startTime: '2021-02-03T00:13:11Z', + pagedTeams: ['team-O9SqT13fsnCstjMi'], + pagedUsers: [], + pagedPolicies: [ + { + policy: { + name: 'Generated Direct User Policy for test_user', + slug: 'directUserPolicySlug-test', + _selfUrl: '/test', + }, + }, + ], + transitions: [{ name: 'ACKED', at: '2021-02-03T01:20:00Z', by: 'test' }], + monitorName: 'vouser-user', + monitorType: 'Manual', + firstAlertUuid: 'firstAlertUuid', + incidentLink: 'https://portal.victorops.com/example', +}; + +export const MOCK_TEAM: Team = { + _selfUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi', + _membersUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/members', + _policiesUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/policies', + _adminsUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/admins', + name: 'test', + slug: 'team-O9SqT13fsnCstjMi', + memberCount: 1, + version: 1, + isDefaultTeam: false, +}; + +export const ESCALATION_POLICIES: EscalationPolicyInfo[] = [ + { + policy: { + name: 'Example', + slug: 'team-zEalMCgwYSA0Lt40', + _selfUrl: '/api-public/v1/policies/team-zEalMCgwYSA0Lt40', + }, + team: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' }, + }, +]; diff --git a/plugins/splunk-on-call/src/api/types.ts b/plugins/splunk-on-call/src/api/types.ts new file mode 100644 index 0000000000..500f2a6291 --- /dev/null +++ b/plugins/splunk-on-call/src/api/types.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EscalationPolicyInfo, + Incident, + OnCall, + Team, + User, +} from '../components/types'; +import { DiscoveryApi } from '@backstage/core'; + +export enum TargetType { + UserValue = 'User', + EscalationPolicyValue = 'EscalationPolicy', +} + +export type IncidentTarget = { + type: TargetType; + slug: string; +}; + +export type TriggerAlarmRequest = { + targets: IncidentTarget[]; + details: string; + summary: string; + userName: string; + isMultiResponder?: boolean; +}; + +export interface SplunkOnCallApi { + /** + * Fetches a list of incidents + */ + getIncidents(): Promise; + + /** + * Fetches the list of users in an escalation policy. + */ + getOnCallUsers(): Promise; + + /** + * Triggers an incident to specific users and/or specific teams. + */ + triggerAlarm(request: TriggerAlarmRequest): Promise; + + /** + * Resolves an incident. + */ + resolveIncident(request: PatchIncidentRequest): Promise; + + /** + * Acknowledge an incident. + */ + acknowledgeIncident(request: PatchIncidentRequest): Promise; + + /** + * Get a list of users for your organization. + */ + getUsers(): Promise; + + /** + * Get a list of teams for your organization. + */ + getTeams(): Promise; + + /** + * Get a list of escalation policies for your organization. + */ + getEscalationPolicies(): Promise; +} + +export type PatchIncidentRequest = { + incidentNames: string[]; + message?: string; +}; + +export type EscalationPolicyResponse = { + policies: EscalationPolicyInfo[]; +}; + +export type ListUserResponse = { + users: User[]; + _selfUrl?: string; +}; + +export type IncidentsResponse = { + incidents: Incident[]; +}; + +export type OnCallsResponse = { + teamsOnCall: OnCall[]; +}; + +export type ClientApiConfig = { + username: string | null; + discoveryApi: DiscoveryApi; +}; + +export type RequestOptions = { + method: string; + headers: HeadersInit; + body?: BodyInit; +}; diff --git a/plugins/splunk-on-call/src/assets/emptystate.svg b/plugins/splunk-on-call/src/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/splunk-on-call/src/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx b/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx new file mode 100644 index 0000000000..ea72b6ca51 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Errors/MissingApiKeyOrApiIdError.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { EmptyState } from '@backstage/core'; +import { Button } from '@material-ui/core'; + +export const MissingApiKeyOrApiIdError = () => ( + + Read More + + } + /> +); diff --git a/plugins/splunk-on-call/src/components/Errors/index.ts b/plugins/splunk-on-call/src/components/Errors/index.ts new file mode 100644 index 0000000000..9698b0bd14 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Errors/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 { MissingApiKeyOrApiIdError } from './MissingApiKeyOrApiIdError'; diff --git a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx new file mode 100644 index 0000000000..41157a2b5e --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { EscalationPolicy } from './EscalationPolicy'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks'; + +const mockSplunkOnCallApi = { + getOnCallUsers: () => [], +}; +const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]); + +describe('Escalation', () => { + it('Handles an empty response', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockImplementationOnce(async () => []); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled(); + }); + + it('Render a list of users', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockImplementationOnce(async () => MOCKED_ON_CALL); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect(getByText('FirstNameTest LastNameTest')).toBeInTheDocument(); + expect(getByText('test@example.com')).toBeInTheDocument(); + expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled(); + }); + + it('Handles errors', async () => { + mockSplunkOnCallApi.getOnCallUsers = jest + .fn() + .mockRejectedValueOnce(new Error('Error message')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText('Error encountered while fetching information. Error message'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx new file mode 100644 index 0000000000..9538a0e73b --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationPolicy.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { EscalationUsersEmptyState } from './EscalationUsersEmptyState'; +import { EscalationUser } from './EscalationUser'; +import { useAsync } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; +import { User } from '../types'; + +type Props = { + users: { [key: string]: User }; + team: string; +}; + +export const EscalationPolicy = ({ users, team }: Props) => { + const api = useApi(splunkOnCallApiRef); + + const { value: userNames, loading, error } = useAsync(async () => { + const oncalls = await api.getOnCallUsers(); + const teamUsernames = oncalls + .filter(oncall => oncall.team?.name === team) + .flatMap(oncall => { + return oncall.oncallNow?.flatMap(oncallNow => { + return oncallNow.users?.flatMap(user => { + return user?.onCalluser?.username; + }); + }); + }); + return teamUsernames; + }); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!userNames?.length) { + return ; + } + + return ( + ON CALL}> + {userNames && + userNames.map( + (userName, index) => + userName && + userName in users && ( + + ), + )} + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationUser.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationUser.tsx new file mode 100644 index 0000000000..4df0719696 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationUser.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 { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import Avatar from '@material-ui/core/Avatar'; +import EmailIcon from '@material-ui/icons/Email'; +import { User } from '../types'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, +}); + +type Props = { + user: User; +}; + +export const EscalationUser = ({ user }: Props) => { + const classes = useStyles(); + + return ( + + + + + + {user.firstName} {user.lastName} + + } + secondary={user.email} + /> + + + + + + + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx b/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx new file mode 100644 index 0000000000..d587011601 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/EscalationUsersEmptyState.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { StatusWarning } from '@backstage/core'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const EscalationUsersEmptyState = () => { + const classes = useStyles(); + return ( + + +
+ +
+
+ +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/Escalation/index.ts b/plugins/splunk-on-call/src/components/Escalation/index.ts new file mode 100644 index 0000000000..ac2db62cd9 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Escalation/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 { EscalationPolicy } from './EscalationPolicy'; diff --git a/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx b/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx new file mode 100644 index 0000000000..f7a0398c55 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/IncidentEmptyState.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import EmptyStateImage from '../../assets/emptystate.svg'; + +export const IncidentsEmptyState = () => { + return ( + + + Nice! No incidents found! + + + EmptyState + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx new file mode 100644 index 0000000000..1bf233de86 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx @@ -0,0 +1,241 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { + ListItem, + ListItemIcon, + ListItemSecondaryAction, + Tooltip, + ListItemText, + makeStyles, + IconButton, + Typography, +} from '@material-ui/core'; +import DoneIcon from '@material-ui/icons/Done'; +import DoneAllIcon from '@material-ui/icons/DoneAll'; +import { + StatusError, + StatusWarning, + StatusOK, + useApi, + alertApiRef, +} from '@backstage/core'; +import { DateTime, Duration } from 'luxon'; +import { Incident, IncidentPhase } from '../types'; +import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; +import { splunkOnCallApiRef } from '../../api/client'; +import { useAsyncFn } from 'react-use'; +import { PatchIncidentRequest } from '../../api/types'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + listItemPrimary: { + fontWeight: 'bold', + }, + listItemIcon: { + minWidth: '1em', + }, + secondaryAction: { + paddingRight: 48, + }, +}); + +type Props = { + incident: Incident; + onIncidentAction: () => void; +}; + +const IncidentPhaseStatus = ({ + currentPhase, +}: { + currentPhase: IncidentPhase; +}) => { + switch (currentPhase) { + case 'UNACKED': + return ; + case 'ACKED': + return ; + default: + return ; + } +}; + +const incidentPhaseTooltip = (currentPhase: IncidentPhase) => { + switch (currentPhase) { + case 'UNACKED': + return 'Triggered'; + case 'ACKED': + return 'Acknowledged'; + default: + return 'Resolved'; + } +}; + +const IncidentAction = ({ + currentPhase, + incidentNames, + resolveAction, + acknowledgeAction, +}: { + currentPhase: string; + incidentNames: string[]; + resolveAction: (args: PatchIncidentRequest) => void; + acknowledgeAction: (args: PatchIncidentRequest) => void; +}) => { + switch (currentPhase) { + case 'UNACKED': + return ( + + acknowledgeAction({ incidentNames })}> + + + + ); + case 'ACKED': + return ( + + resolveAction({ incidentNames })}> + + + + ); + default: + return <>; + } +}; + +export const IncidentListItem = ({ incident, onIncidentAction }: Props) => { + const classes = useStyles(); + const duration = + new Date().getTime() - new Date(incident.startTime!).getTime(); + const createdAt = DateTime.local() + .minus(Duration.fromMillis(duration)) + .toRelative({ locale: 'en' }); + const alertApi = useApi(alertApiRef); + const api = useApi(splunkOnCallApiRef); + + const hasBeenManuallyTriggered = incident.monitorName?.includes('vouser-'); + + const user = hasBeenManuallyTriggered + ? incident.monitorName?.replace('vouser-', '') + : incident.monitorName; + + const [ + { value: resolveValue, error: resolveError }, + handleResolveIncident, + ] = useAsyncFn( + async ({ incidentNames }: PatchIncidentRequest) => + await api.resolveIncident({ + incidentNames, + }), + ); + + const [ + { value: acknowledgeValue, error: acknowledgeError }, + handleAcknowledgeIncident, + ] = useAsyncFn( + async ({ incidentNames }: PatchIncidentRequest) => + await api.acknowledgeIncident({ + incidentNames, + }), + ); + + useEffect(() => { + if (acknowledgeValue) { + alertApi.post({ + message: `Incident successfully acknowledged`, + }); + } + + if (resolveValue) { + alertApi.post({ + message: `Incident successfully resolved`, + }); + } + if (resolveValue || acknowledgeValue) { + onIncidentAction(); + } + }, [acknowledgeValue, resolveValue, alertApi, onIncidentAction]); + + if (acknowledgeError) { + alertApi.post({ + message: `Failed to acknowledge incident. ${acknowledgeError.message}`, + severity: 'error', + }); + } + + if (resolveError) { + alertApi.post({ + message: `Failed to resolve incident. ${resolveError.message}`, + severity: 'error', + }); + } + + return ( + + + +
+ +
+
+
+ + Created {createdAt} {user && `by ${user}`} + + } + /> + + {incident.incidentLink && incident.incidentNumber && ( + + + + + + + + + )} +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx new file mode 100644 index 0000000000..d1f973735c --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Incidents } from './Incidents'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + alertApiRef, + ApiProvider, + ApiRegistry, + createApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks'; + +const mockIdentityApi: Partial = { + getUserId: () => 'test', +}; + +const mockSplunkOnCallApi = { + getIncidents: () => [], + getTeams: () => [], +}; +const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [identityApiRef, mockIdentityApi], + [splunkOnCallApiRef, mockSplunkOnCallApi], +]); + +describe('Incidents', () => { + it('Renders an empty state when there are no incidents', async () => { + mockSplunkOnCallApi.getTeams = jest + .fn() + .mockImplementationOnce(async () => [MOCK_TEAM]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + }); + + it('Renders all incidents', async () => { + mockSplunkOnCallApi.getIncidents = jest + .fn() + .mockImplementationOnce(async () => [MOCK_INCIDENT]); + + mockSplunkOnCallApi.getTeams = jest + .fn() + .mockImplementationOnce(async () => [MOCK_TEAM]); + const { + getByText, + getByTitle, + getAllByTitle, + getByLabelText, + queryByTestId, + } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('user', { + exact: false, + }), + ).toBeInTheDocument(); + expect(getByText('test-incident')).toBeInTheDocument(); + expect(getByTitle('Acknowledged')).toBeInTheDocument(); + expect(getByLabelText('Status warning')).toBeInTheDocument(); + + // assert links, mailto and hrefs, date calculation + expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1); + }); + + it('Handle errors', async () => { + mockSplunkOnCallApi.getIncidents = jest + .fn() + .mockRejectedValueOnce(new Error('Error occurred')); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Error encountered while fetching information. Error occurred'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx new file mode 100644 index 0000000000..e1a79314b9 --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { List, ListSubheader } from '@material-ui/core'; +import { IncidentListItem } from './IncidentListItem'; +import { IncidentsEmptyState } from './IncidentEmptyState'; +import { useAsyncFn } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { useApi, Progress } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; + +type Props = { + refreshIncidents: boolean; + team: string; +}; + +export const Incidents = ({ refreshIncidents, team }: Props) => { + const api = useApi(splunkOnCallApiRef); + + const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn( + async () => { + const allIncidents = await api.getIncidents(); + const teams = await api.getTeams(); + const teamSlug = teams.find(teamValue => teamValue.name === team)?.slug; + const filteredIncidents = teamSlug + ? allIncidents.filter(incident => + incident.pagedTeams?.includes(teamSlug), + ) + : []; + return filteredIncidents; + }, + ); + + useEffect(() => { + getIncidents(); + }, [refreshIncidents, getIncidents]); + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + if (!incidents?.length) { + return ; + } + + return ( + INCIDENTS}> + {incidents!.map((incident, index) => ( + getIncidents()} + key={index} + incident={incident} + /> + ))} + + ); +}; diff --git a/plugins/splunk-on-call/src/components/Incident/index.ts b/plugins/splunk-on-call/src/components/Incident/index.ts new file mode 100644 index 0000000000..fb2702602b --- /dev/null +++ b/plugins/splunk-on-call/src/components/Incident/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 { Incidents } from './Incidents'; diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx new file mode 100644 index 0000000000..b1280ab21c --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, waitFor, fireEvent, act } from '@testing-library/react'; +import { SplunkOnCallCard } from './SplunkOnCallCard'; +import { Entity } from '@backstage/catalog-model'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + alertApiRef, + ApiProvider, + ApiRegistry, + ConfigApi, + configApiRef, + ConfigReader, + createApiRef, +} from '@backstage/core'; +import { + splunkOnCallApiRef, + UnauthorizedError, + SplunkOnCallClient, +} from '../api'; +import { + ESCALATION_POLICIES, + MOCKED_ON_CALL, + MOCKED_USER, + MOCK_INCIDENT, + MOCK_TEAM, +} from '../api/mocks'; + +const mockSplunkOnCallApi: Partial = { + getUsers: async () => [], + getIncidents: async () => [MOCK_INCIDENT], + getOnCallUsers: async () => MOCKED_ON_CALL, + getTeams: async () => [MOCK_TEAM], + getEscalationPolicies: async () => ESCALATION_POLICIES, +}; + +const configApi: ConfigApi = new ConfigReader({ + splunkOnCall: { + username: MOCKED_USER.username, + }, +}); + +const apis = ApiRegistry.from([ + [splunkOnCallApiRef, mockSplunkOnCallApi], + [configApiRef, configApi], + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], +]); +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'splunkoncall-test', + annotations: { + 'splunk.com/on-call-team': 'Example', + }, + }, +}; + +describe('SplunkOnCallCard', () => { + it('Render splunkoncall', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockImplementationOnce(async () => [MOCKED_USER]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Missing or invalid Splunk On-Call API key and/or API id'), + ).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + it('opens the dialog when trigger button is clicked', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockImplementationOnce(async () => [MOCKED_USER]); + + const { getByText, queryByTestId, getByTestId, getByRole } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Create Incident')).toBeInTheDocument(); + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx new file mode 100644 index 0000000000..84d94d60d3 --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useCallback } from 'react'; +import { + useApi, + Progress, + HeaderIconLinkRow, + MissingAnnotationEmptyState, + configApiRef, + EmptyState, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { + Button, + makeStyles, + Card, + CardHeader, + Divider, + CardContent, + Typography, +} from '@material-ui/core'; +import { Incidents } from './Incident'; +import { EscalationPolicy } from './Escalation'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { splunkOnCallApiRef, UnauthorizedError } from '../api'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import { TriggerDialog } from './TriggerDialog'; +import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError'; +import { User } from './types'; + +const useStyles = makeStyles({ + triggerAlarm: { + paddingTop: 0, + paddingBottom: 0, + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + lineHeight: 1.5, + '&:hover, &:focus, &.focus': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }, +}); + +export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team'; + +export const MissingTeamAnnotation = () => ( + +); + +export const MissingUsername = () => ( + + + +); + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]); + +type Props = { + entity: Entity; +}; + +export const SplunkOnCallCard = ({ entity }: Props) => { + const classes = useStyles(); + const config = useApi(configApiRef); + const api = useApi(splunkOnCallApiRef); + const [showDialog, setShowDialog] = useState(false); + const [refreshIncidents, setRefreshIncidents] = useState(false); + const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM]; + + const username = config.getOptionalString('splunkOnCall.username'); + + const handleRefresh = useCallback(() => { + setRefreshIncidents(x => !x); + }, []); + + const handleDialog = useCallback(() => { + setShowDialog(x => !x); + }, []); + + const { value: users, loading, error } = useAsync(async () => { + const allUsers = await api.getUsers(); + const usersHashMap = allUsers.reduce( + (map: Record, obj: User) => { + if (obj.username) { + map[obj.username] = obj; + } + return map; + }, + {}, + ); + return { usersHashMap, userList: allUsers }; + }); + + const incidentCreator = + username && users?.userList.find(user => user.username === username); + + if (error instanceof UnauthorizedError) { + return ; + } + + if (error) { + return ( + + Error encountered while fetching information. {error.message} + + ); + } + + if (loading) { + return ; + } + + const Content = () => { + if (!team) { + return ; + } + if (!username || !incidentCreator) { + return ; + } + + return ( + <> + + {users?.usersHashMap && team && ( + + )} + {users && incidentCreator && ( + + )} + + ); + }; + + const triggerLink = { + label: 'Create Incident', + action: ( + + ), + icon: , + }; + + return ( + + Team: {team}, + username && ( + + ), + ]} + /> + + + + + + ); +}; diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx new file mode 100644 index 0000000000..4222cbfbeb --- /dev/null +++ b/plugins/splunk-on-call/src/components/SplunkOnCallPage.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 { Grid, makeStyles } from '@material-ui/core'; +import { + Content, + ContentHeader, + Page, + Header, + SupportButton, +} from '@backstage/core'; +import { SplunkOnCallCard } from './SplunkOnCallCard'; +import { useEntity } from '@backstage/plugin-catalog-react'; + +const useStyles = makeStyles(() => ({ + overflowXScroll: { + overflowX: 'scroll', + }, +})); + +export type SplunkOnCallPageProps = { + title?: string; + subtitle?: string; + pageTitle?: string; +}; + +export const SplunkOnCallPage = ({ + title, + subtitle, + pageTitle, +}: SplunkOnCallPageProps): JSX.Element => { + const classes = useStyles(); + const { entity } = useEntity(); + + return ( + +
+ + + + This is used to help you automate incident management. + + + + + + + + + + ); +}; + +SplunkOnCallPage.defaultProps = { + title: 'Splunk On-Call', + subtitle: 'Automate incident management', + pageTitle: 'Dashboard', +}; diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx new file mode 100644 index 0000000000..741a15fb41 --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { + ApiRegistry, + alertApiRef, + createApiRef, + ApiProvider, +} from '@backstage/core'; +import { splunkOnCallApiRef } from '../../api'; +import { TriggerDialog } from './TriggerDialog'; +import { ESCALATION_POLICIES, MOCKED_USER } from '../../api/mocks'; + +describe('TriggerDialog', () => { + const mockTriggerAlarmFn = jest.fn(); + const mockSplunkOnCallApi = { + triggerAlarm: mockTriggerAlarmFn, + getEscalationPolicies: async () => ESCALATION_POLICIES, + }; + + const apis = ApiRegistry.from([ + [ + alertApiRef, + createApiRef({ + id: 'core.alert', + description: 'Used to report alerts and forward them to the app', + }), + ], + [splunkOnCallApiRef, mockSplunkOnCallApi], + ]); + + it('open the dialog and trigger an alarm', async () => { + const { getByText, getByRole, getAllByRole, getByTestId } = render( + wrapInTestApp( + + {}} + users={[MOCKED_USER]} + onIncidentCreated={() => {}} + /> + , + ), + ); + + expect(getByRole('dialog')).toBeInTheDocument(); + expect( + getByText('This action will trigger an incident', { + exact: false, + }), + ).toBeInTheDocument(); + const summary = getByTestId('trigger-summary-input'); + const body = getByTestId('trigger-body-input'); + const behavior = getByTestId('trigger-select-behavior'); + const description = 'Test Trigger Alarm'; + await act(async () => { + fireEvent.change(summary, { target: { value: description } }); + fireEvent.change(body, { target: { value: description } }); + fireEvent.change(behavior, { target: { value: '0' } }); + fireEvent.mouseDown(getAllByRole('button')[0]); + }); + + // Trigger user targets select + const options = getAllByRole('option'); + await act(async () => { + fireEvent.click(options[0]); + fireEvent.keyDown(options[0], { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + }); + + // Trigger policy targets select + await act(async () => { + fireEvent.mouseDown(getAllByRole('button')[1]); + }); + const policiesOptions = getAllByRole('option'); + await act(async () => { + fireEvent.click(policiesOptions[0]); + }); + + // Trigger incident creation button + const triggerButton = getByTestId('trigger-button'); + await act(async () => { + fireEvent.click(triggerButton); + }); + expect(mockTriggerAlarmFn).toHaveBeenCalled(); + expect(mockTriggerAlarmFn).toHaveBeenCalledWith({ + summary: description, + details: description, + userName: 'test_user', + targets: [ + { slug: 'test_user', type: 'User' }, + { slug: 'team-zEalMCgwYSA0Lt40', type: 'EscalationPolicy' }, + ], + isMultiResponder: false, + }); + }); +}); diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx new file mode 100644 index 0000000000..8132db985b --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.tsx @@ -0,0 +1,365 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + TextField, + DialogActions, + Button, + DialogContent, + Typography, + CircularProgress, + Select, + MenuItem, + Input, + Chip, + createStyles, + makeStyles, + Theme, + FormControl, + InputLabel, +} from '@material-ui/core'; +import { useApi, alertApiRef } from '@backstage/core'; +import { useAsync, useAsyncFn } from 'react-use'; +import { splunkOnCallApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { User } from '../types'; +import { IncidentTarget, TargetType } from '../../api/types'; + +const MenuProps = { + PaperProps: { + style: { + width: 250, + }, + }, +}; + +type Props = { + users: User[]; + incidentCreator: User; + showDialog: boolean; + handleDialog: () => void; + onIncidentCreated: () => void; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: 2, + }, + formControl: { + margin: theme.spacing(1), + minWidth: `calc(100% - ${theme.spacing(2)}px)`, + }, + targets: { + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + }), +); + +export const TriggerDialog = ({ + users, + incidentCreator, + showDialog, + handleDialog, + onIncidentCreated: onIncidentCreated, +}: Props) => { + const alertApi = useApi(alertApiRef); + const api = useApi(splunkOnCallApiRef); + const classes = useStyles(); + + const [userTargets, setUserTargets] = useState([]); + const [policyTargets, setPolicyTargets] = useState([]); + const [detailsValue, setDetails] = useState(''); + const [summaryValue, setSummary] = useState(''); + const [isMultiResponderValue, setIsMultiResponder] = useState('1'); + + const [ + { value, loading: triggerLoading, error: triggerError }, + handleTriggerAlarm, + ] = useAsyncFn( + async ( + summary: string, + details: string, + userName: string, + targets: IncidentTarget[], + isMultiResponder: boolean, + ) => + await api.triggerAlarm({ + summary, + details, + userName, + targets, + isMultiResponder, + }), + ); + + const { + value: policies, + loading: policiesLoaading, + error: policiesError, + } = useAsync(async () => { + const allPolicies = await api.getEscalationPolicies(); + return allPolicies; + }); + + const handleUserTargets = (event: React.ChangeEvent<{ value: unknown }>) => { + setUserTargets(event.target.value as string[]); + }; + + const handlePolicyTargets = ( + event: React.ChangeEvent<{ value: unknown }>, + ) => { + setPolicyTargets(event.target.value as string[]); + }; + + const detailsChanged = (event: React.ChangeEvent) => { + setDetails(event.target.value); + }; + + const summaryChanged = (event: React.ChangeEvent) => { + setSummary(event.target.value); + }; + + const isMultiResponderChanged = ( + event: React.ChangeEvent<{ value: unknown }>, + ) => { + setIsMultiResponder(event.target.value as string); + }; + + const targets = (): IncidentTarget[] => [ + ...userTargets.map(user => ({ slug: user, type: TargetType.UserValue })), + ...policyTargets.map(user => ({ + slug: user, + type: TargetType.EscalationPolicyValue, + })), + ]; + + useEffect(() => { + if (value) { + alertApi.post({ + message: `Alarm successfully triggered`, + }); + onIncidentCreated(); + handleDialog(); + } + }, [value, alertApi, handleDialog, onIncidentCreated]); + + if (triggerError) { + alertApi.post({ + message: `Failed to trigger alarm. ${triggerError.message}`, + severity: 'error', + }); + } + + return ( + + This action will trigger an incident + + + Created by:{' '} + + {incidentCreator?.firstName} {incidentCreator?.lastName} + + + + + If the issue you are seeing does not need urgent attention, please + get in touch with the responsible team using their preferred + communications channel. You can find information about the owner of + this entity in the "About" card. If the issue is urgent, please + don't hesitate to trigger the alert. + + + + Please describe the problem you want to report. Be as descriptive as + possible. Your signed in user and a reference to the current page will + automatically be amended to the alarm so that the receiver can reach + out to you if necessary. + +
+ + Select the targets + +
+ + Select Users + + + + Select Teams / Policies + + +
+
+ + Acknowledge Behavior + + + + + + +
+ + + + +
+ ); +}; diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/index.ts b/plugins/splunk-on-call/src/components/TriggerDialog/index.ts new file mode 100644 index 0000000000..655cef8504 --- /dev/null +++ b/plugins/splunk-on-call/src/components/TriggerDialog/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 { TriggerDialog } from './TriggerDialog'; diff --git a/plugins/splunk-on-call/src/components/types.ts b/plugins/splunk-on-call/src/components/types.ts new file mode 100644 index 0000000000..de5718cb28 --- /dev/null +++ b/plugins/splunk-on-call/src/components/types.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IncidentTarget } from '../api/types'; + +export type Team = { + name?: string; + slug?: string; + memberCount?: number; + version?: number; + isDefaultTeam?: boolean; + _selfUrl?: string; + _policiesUrl?: string; + _membersUrl?: string; + _adminsUrl?: string; +}; + +export type OnCall = { + team?: OnCallTeamResource; + oncallNow?: OnCallNowResource[]; +}; + +export type OnCallTeamResource = { + name?: string; + slug?: string; +}; + +export type OnCallNowResource = { + escalationPolicy?: OnCallEscalationPolicyResource; + users?: OnCallUsersResource[]; +}; + +export type OnCallEscalationPolicyResource = { + name?: string; + slug?: string; +}; + +export type OnCallUsersResource = { + onCalluser?: OnCallUser; +}; + +export type OnCallUser = { + username?: string; +}; + +export type User = { + firstName?: string; + lastName?: string; + displayName?: string; + username?: string; + email?: string; + createdAt?: string; + passwordLastUpdated?: string; + verified?: boolean; + _selfUrl?: string; +}; + +export type CreateIncidentRequest = { + summary: string; + details: string; + userName: string; + targets: IncidentTarget; + isMultiResponder: boolean; +}; + +export type IncidentPhase = 'UNACKED' | 'ACKED' | 'RESOLVED'; + +export type Incident = { + incidentNumber?: string; + startTime?: string; + currentPhase: IncidentPhase; + entityState?: string; + entityType?: string; + routingKey?: string; + alertCount?: number; + lastAlertTime?: string; + lastAlertId?: string; + entityId?: string; + host?: string; + service?: string; + pagedUsers?: string[]; + pagedTeams?: string[]; + entityDisplayName?: string; + pagedPolicies?: EscalationPolicyInfo[]; + transitions?: IncidentTransition[]; + firstAlertUuid?: string; + monitorName?: string; + monitorType?: string; + incidentLink?: string; +}; + +export type EscalationPolicyInfo = { + policy: EscalationPolicySummary; + team?: EscalationPolicyTeam; +}; + +export type IncidentTransition = { + name?: string; + at?: string; + by?: string; + message?: string; + manually?: boolean; + alertId?: string; + alertUrl?: string; +}; + +export type EscalationPolicySummary = { + name: string; + slug: string; + _selfUrl: string; +}; + +export type EscalationPolicyTeam = { + name: string; + slug: string; +}; diff --git a/plugins/splunk-on-call/src/index.ts b/plugins/splunk-on-call/src/index.ts new file mode 100644 index 0000000000..c8de96f8ea --- /dev/null +++ b/plugins/splunk-on-call/src/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + splunkOnCallPlugin, + splunkOnCallPlugin as plugin, + SplunkOnCallPage, +} from './plugin'; +export { + isPluginApplicableToEntity, + SplunkOnCallCard, +} from './components/SplunkOnCallCard'; +export { + SplunkOnCallClient, + splunkOnCallApiRef, + UnauthorizedError, +} from './api/client'; diff --git a/plugins/splunk-on-call/src/plugin.test.ts b/plugins/splunk-on-call/src/plugin.test.ts new file mode 100644 index 0000000000..b846eba706 --- /dev/null +++ b/plugins/splunk-on-call/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 { splunkOnCallPlugin } from './plugin'; + +describe('splunk-on-call', () => { + it('should export plugin', () => { + expect(splunkOnCallPlugin).toBeDefined(); + }); +}); diff --git a/plugins/splunk-on-call/src/plugin.ts b/plugins/splunk-on-call/src/plugin.ts new file mode 100644 index 0000000000..835cda48df --- /dev/null +++ b/plugins/splunk-on-call/src/plugin.ts @@ -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 { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + configApiRef, + createRoutableExtension, +} from '@backstage/core'; +import { splunkOnCallApiRef, SplunkOnCallClient } from './api'; + +export const rootRouteRef = createRouteRef({ + title: 'splunk-on-call', +}); + +export const splunkOnCallPlugin = createPlugin({ + id: 'splunk-on-call', + apis: [ + createApiFactory({ + api: splunkOnCallApiRef, + deps: { discoveryApi: discoveryApiRef, configApi: configApiRef }, + factory: ({ configApi, discoveryApi }) => + SplunkOnCallClient.fromConfig(configApi, discoveryApi), + }), + ], + routes: { + root: rootRouteRef, + }, +}); + +export const SplunkOnCallPage = splunkOnCallPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SplunkOnCallPage').then(m => m.SplunkOnCallPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/splunk-on-call/src/setupTests.ts b/plugins/splunk-on-call/src/setupTests.ts new file mode 100644 index 0000000000..0bfa67b49a --- /dev/null +++ b/plugins/splunk-on-call/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index cf6f360fe6..697469bf7a 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,73 @@ # @backstage/plugin-tech-radar +## 0.3.6 + +### Patch Changes + +- 9f2b3a26e: Added a dialog box that will show up when a you click on link on the radar and display the description if provided. +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [1407b34c6] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [3a58084b6] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + +## 0.3.5 + +### Patch Changes + +- 804502a5c: Migrated to new composability API, exporting the plugin instance as `techRadarPlugin` and the page as `TechRadarPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + +## 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 + +- Updated dependencies [2527628e1] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + ## 0.3.0 ### Minor Changes diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index cbe4dd22eb..21f90ec432 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -83,13 +83,20 @@ const getHardCodedData = () => rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], entries: [ { - moved: 0, - ring: 'use', url: '#', key: 'github-actions', id: 'github-actions', title: 'GitHub Actions', quadrant: 'infrastructure', + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat', + }, + ], }, ], }); diff --git a/plugins/tech-radar/dev/index.tsx b/plugins/tech-radar/dev/index.tsx index 92eb6da567..0e9495394f 100644 --- a/plugins/tech-radar/dev/index.tsx +++ b/plugins/tech-radar/dev/index.tsx @@ -14,7 +14,14 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src'; +import { techRadarPlugin, TechRadarPage } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(techRadarPlugin) + .addPage({ + title: 'Tech Radar', + element: , + }) + .render(); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 269e0e0b4f..3e90dc8ece 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.0", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/tech-radar" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,9 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", @@ -35,14 +43,14 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/color": "^3.0.1", - "@types/d3-force": "^1.2.1", + "@types/d3-force": "^2.1.1", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 583a7c91e0..bed45e63fc 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,9 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { moved: e.moved, }; }), + moved: entry.timeline[0].moved, + description: entry.timeline[0].description, + url: entry.url, }; }); }; diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx new file mode 100644 index 0000000000..b19794042e --- /dev/null +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; + +import { Props, RadarDescription } from './RadarDescription'; + +const minProps: Props = { + open: true, + title: 'example-title', + description: 'example-description', + onClose: () => {}, +}; + +describe('RadarDescription', () => { + it('should render', () => { + render( + + + , + ); + + const radarDescription = screen.getByTestId('radar-description'); + expect(radarDescription).toBeInTheDocument(); + expect(screen.getByText(String(minProps.description))).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx new file mode 100644 index 0000000000..20c489f630 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import { Button, DialogActions, DialogContent } from '@material-ui/core'; +import LinkIcon from '@material-ui/icons/Link'; + +export type Props = { + open: boolean; + onClose: () => void; + title: string; + description: string; + url?: string; +}; + +const RadarDescription = (props: Props): JSX.Element => { + const { open, onClose, title, description, url } = props; + + const handleClick = () => { + onClose(); + if (url) { + window.location.href = url; + } + }; + + return ( + + + {title} + + {description} + {url && ( + + + + )} + + ); +}; + +export { RadarDescription }; diff --git a/plugins/tech-radar/src/components/RadarDescription/index.ts b/plugins/tech-radar/src/components/RadarDescription/index.ts new file mode 100644 index 0000000000..027407718e --- /dev/null +++ b/plugins/tech-radar/src/components/RadarDescription/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 { RadarDescription } from './RadarDescription'; +export type { Props } from './RadarDescription'; diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx index 2d24f301d1..0424624007 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -15,7 +15,8 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; @@ -29,6 +30,12 @@ const minProps: Props = { color: 'red', }; +const optionalProps: Props = { + ...minProps, + title: 'example-title', + description: 'example-description', +}; + describe('RadarEntry', () => { beforeAll(() => { GetBBoxPolyfill.create(0, 0, 1000, 500); @@ -38,8 +45,8 @@ describe('RadarEntry', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( + it('should render link only', () => { + render( @@ -47,10 +54,29 @@ describe('RadarEntry', () => { , ); - const radarEntry = rendered.getByTestId('radar-entry'); + const radarEntry = screen.getByTestId('radar-entry'); const { x, y } = minProps; expect(radarEntry).toBeInTheDocument(); expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`); - expect(rendered.getByText(String(minProps.value))).toBeInTheDocument(); + expect(screen.getByText(String(minProps.value))).toBeInTheDocument(); + }); + + it('should render with description', () => { + render( + + + + + , + ); + + userEvent.click(screen.getByRole('button')); + + const radarEntry = screen.getByTestId('radar-entry'); + expect(radarEntry).toBeInTheDocument(); + + const radarDescription = screen.getByTestId('radar-description'); + expect(radarDescription).toBeInTheDocument(); + expect(screen.getByText(String(minProps.value))).toBeInTheDocument(); }); }); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 29d35e01e2..fb47a75c71 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import { WithLink } from '../../utils/components'; +import { RadarDescription } from '../RadarDescription'; export type Props = { x: number; @@ -25,6 +26,8 @@ export type Props = { color: string; url?: string; moved?: number; + description?: string; + title?: string; onMouseEnter?: (event: React.MouseEvent) => void; onMouseLeave?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void; @@ -59,9 +62,12 @@ const makeBlip = (color: string, moved?: number) => { const RadarEntry = (props: Props): JSX.Element => { const classes = useStyles(props); + const [open, setOpen] = React.useState(false); const { moved, + description, + title, color, url, value, @@ -74,6 +80,18 @@ const RadarEntry = (props: Props): JSX.Element => { const blip = makeBlip(color, moved); + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const toggle = () => { + setOpen(!open); + }; + return ( { onClick={onClick} data-testid="radar-entry" > - - {blip} - + {' '} + {open && ( + + )} + {description ? ( +
+ {blip} + + ) : ( + + {blip} + + )} {value} diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index e319846d78..d4ef38d5ee 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import type { Quadrant, Ring, Entry } from '../../utils/types'; import { WithLink } from '../../utils/components'; +import { RadarDescription } from '../RadarDescription'; type Segments = { [k: number]: { [k: number]: Entry[] }; @@ -71,12 +72,12 @@ const useStyles = makeStyles(theme => ({ 'font-feature-settings': 'pnum', }, entry: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', userSelect: 'none', fontSize: '11px', }, entryLink: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', }, })); @@ -105,6 +106,62 @@ const RadarLegend = (props: Props): JSX.Element => { onEntryMouseLeave?: Props['onEntryMouseEnter']; }; + type RadarLegendLinkProps = { + url?: string; + description?: string; + title?: string; + }; + + const RadarLegendLink = ({ + url, + description, + title, + }: RadarLegendLinkProps) => { + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const toggle = () => { + setOpen(!open); + }; + + if (description) { + return ( + <> + + {title} + + {open && ( + + )} + + ); + } + return ( + + {title} + + ); + }; + const RadarLegendRing = ({ ring, entries, @@ -129,9 +186,11 @@ const RadarLegend = (props: Props): JSX.Element => { onEntryMouseLeave && (() => onEntryMouseLeave(entry)) } > - - {entry.title} - + ))} diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index ee5cf8e131..918015001b 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -14,16 +14,19 @@ * limitations under the License. */ -import React from 'react'; -import { render, waitForElement } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { + MockErrorApi, + renderInTestApp, + wrapInTestApp, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; -import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; - +import { ThemeProvider } from '@material-ui/core'; +import { render, waitForElement } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarPage } from './RadarPage'; -import { act } from 'react-dom/test-utils'; -import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils'; describe('RadarPage', () => { beforeAll(() => { @@ -67,12 +70,10 @@ describe('RadarPage', () => { svgProps: { 'data-testid': 'tech-radar-svg' }, }; - const { getByText, getByTestId } = render( - wrapInTestApp( - - - , - ), + const { getByText, getByTestId } = await renderInTestApp( + + + , ); await waitForElement(() => getByTestId('tech-radar-svg')); @@ -94,7 +95,7 @@ describe('RadarPage', () => { svgProps: { 'data-testid': 'tech-radar-svg' }, }; - const { queryByTestId } = render( + const { queryByTestId } = await renderInTestApp( diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 7f68ffe021..4dd11c6ccf 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -73,7 +73,9 @@ const RadarPlot = (props: Props): JSX.Element => { color={entry.color || ''} value={(entry?.index || 0) + 1} url={entry.url} + description={entry.description} moved={entry.moved} + title={entry.title} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} onMouseLeave={onEntryMouseLeave && (() => onEntryMouseLeave(entry))} /> diff --git a/plugins/tech-radar/src/index.ts b/plugins/tech-radar/src/index.ts index d7b4921e9a..b13e0fbeed 100644 --- a/plugins/tech-radar/src/index.ts +++ b/plugins/tech-radar/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + techRadarPlugin, + techRadarPlugin as plugin, + TechRadarPage, +} from './plugin'; export { RadarPage as Router } from './components/RadarPage'; diff --git a/plugins/tech-radar/src/plugin.test.ts b/plugins/tech-radar/src/plugin.test.ts index 6ae65f8271..f3f2fdbd77 100644 --- a/plugins/tech-radar/src/plugin.test.ts +++ b/plugins/tech-radar/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { techRadarPlugin } from './plugin'; describe('tech-radar', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(techRadarPlugin).toBeDefined(); }); }); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index d8dc41af2e..63af1f390f 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -14,8 +14,26 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core'; -export const plugin = createPlugin({ - id: 'tech-radar', +const rootRouteRef = createRouteRef({ + title: 'Tech Radar', }); + +export const techRadarPlugin = createPlugin({ + id: 'tech-radar', + routes: { + root: rootRouteRef, + }, +}); + +export const TechRadarPage = techRadarPlugin.provide( + createRoutableExtension({ + component: () => import('./components/RadarPage').then(m => m.RadarPage), + mountPoint: rootRouteRef, + }), +); 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/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 3322a67dcb..e4b2ef6fbe 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -68,6 +68,8 @@ export type Entry = { url?: string; // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved moved?: MovedState; + // Most recent description to display in the UI + description?: string; active?: boolean; timeline?: Array; }; diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 6557dfe924..49e3fdbc44 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,10 +1,273 @@ # @backstage/plugin-techdocs-backend +## 0.6.2 + +### Patch Changes + +- f37992797: Got rid of some `attr` and cleaned up a bit in the TechDocs config schema. +- Updated dependencies [bad21a085] +- Updated dependencies [2499f6cde] +- Updated dependencies [a1f5e6545] +- Updated dependencies [1e4ddd71d] + - @backstage/catalog-model@0.7.2 + - @backstage/techdocs-common@0.4.2 + - @backstage/config@0.1.3 + +## 0.6.1 + +### Patch Changes + +- b0a41c707: Add etag of the prepared file tree to techdocs_metadata.json in the storage +- Updated dependencies [16fb1d03a] +- Updated dependencies [491f3a0ec] +- Updated dependencies [434b4e81a] +- Updated dependencies [fb28da212] +- Updated dependencies [26e143e60] +- Updated dependencies [c6655413d] +- Updated dependencies [44414239f] +- Updated dependencies [b0a41c707] + - @backstage/backend-common@0.5.4 + - @backstage/techdocs-common@0.4.1 + +## 0.6.0 + +### Minor Changes + +- 08142b256: URL Preparer will now use proper etag based caching introduced in https://github.com/backstage/backstage/pull/4120. Previously, builds used to be cached for 30 minutes. + +### Patch Changes + +- 08142b256: TechDocs will throw warning in backend logs when legacy git preparer or dir preparer is used to preparer docs. Migrate to URL Preparer by updating `backstage.io/techdocs-ref` annotation to be prefixed with `url:`. + Detailed docs are here https://backstage.io/docs/features/techdocs/how-to-guides#how-to-use-url-reader-in-techdocs-prepare-step + See benefits and reason for doing so https://github.com/backstage/backstage/issues/4409 +- Updated dependencies [77ad0003a] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] + - @backstage/techdocs-common@0.4.0 + - @backstage/backend-common@0.5.3 + +## 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 + +- 8804e8981: Using @backstage/integration package for GitHub/GitLab/Azure tokens and request options. + + Most probably you do not have to make any changes in the app because of this change. + However, if you are using the `DirectoryPreparer` or `CommonGitPreparer` exported by + `@backstage/techdocs-common` package, you now need to add pass in a `config` (from `@backstage/config`) + instance as argument. + + ``` + + const directoryPreparer = new DirectoryPreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + + const directoryPreparer = new DirectoryPreparer(config, logger); + const commonGitPreparer = new CommonGitPreparer(config, logger); + ``` + +- 359f9d2d8: Added configuration schema for the commonly used properties of techdocs and techdocs-backend plugins +- Updated dependencies [8804e8981] + - @backstage/techdocs-common@0.3.1 + +## 0.5.0 + +### Minor Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +### Patch Changes + +- Updated dependencies [a8573e53b] + - @backstage/techdocs-common@0.3.0 + +## 0.4.0 + +### Minor Changes + +- dae4f3983: _Breaking changes_ + + 1. Added option to use Google Cloud Storage as a choice to store the static generated files for TechDocs. + It can be configured using `techdocs.publisher.type` option in `app-config.yaml`. + Step-by-step guide to configure GCS is available here https://backstage.io/docs/features/techdocs/using-cloud-storage + Set `techdocs.publisher.type` to `'local'` if you want to continue using local filesystem to store TechDocs files. + + 2. `techdocs.builder` is now required and can be set to `'local'` or `'external'`. (Set it to `'local'` for now, since CI/CD build + workflow for TechDocs will be available soon (in few weeks)). + 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. + If builder is set to `'external'`, `techdocs-backend` will only fetch the docs and will NOT try to generate and publish. In this case of `'external'`, + we assume that docs are being built in the CI/CD pipeline of the repository. + TechDocs will not assume a default value for `techdocs.builder`. It is better to explicitly define it in the `app-config.yaml`. + + 3. When configuring TechDocs in your backend, there is a difference in how a new publisher is created. + + ``` + --- const publisher = new LocalPublish(logger, discovery); + +++ const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. + + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + + 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs + to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - + app should only import `@backstage/plugin-techdocs` and `@backstage/plugin-techdocs-backend`. + + _Patch changes_ + + 1. See all of TechDocs config options and its documentation https://backstage.io/docs/features/techdocs/configuration + + 2. Logic about serving static files and metadata retrieval have been abstracted away from the router in `techdocs-backend` to the instance of publisher. + + 3. Removed Material UI Spinner from TechDocs header. Spinners cause unnecessary UX distraction. + Case 1 (when docs are built and are to be served): Spinners appear for a split second before the name of site shows up. This unnecessarily distracts eyes because spinners increase the size of the Header. A dot (.) would do fine. Definitely more can be done. + Case 2 (when docs are being generated): There is already a linear progress bar (which is recommended in Storybook). + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [dae4f3983] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/techdocs-common@0.2.0 + - @backstage/backend-common@0.4.1 + +## 0.3.2 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + ## 0.3.1 ### Patch Changes -- ae95c7ff3: Update URL auth format for Gitlab clone +- ae95c7ff3: Update URL auth format for GitLab clone - Updated dependencies [612368274] - Updated dependencies [08835a61d] - Updated dependencies [a9fd599f7] @@ -67,7 +330,7 @@ Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. -- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens. +- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for GitHub, GitLab, Azure & GitHub Enterprise access tokens. ### Detail: diff --git a/plugins/techdocs-backend/README.md b/plugins/techdocs-backend/README.md index 625f0910b5..c2a1cb99aa 100644 --- a/plugins/techdocs-backend/README.md +++ b/plugins/techdocs-backend/README.md @@ -18,30 +18,13 @@ yarn start ## What techdocs-backend does -This plugin is the backend part of the techdocs plugin. It provides building and serving of your docs without having to use another service and hosting provider. To use it set your techdocs storageUrl in your `app-config.yml` to `http://localhost:7000/api/techdocs/static/docs`. +This plugin is the backend part of the techdocs plugin. It provides serving and building of documentation for any entity. +To configure various storage providers and building options, see http://backstage.io/docs/features/techdocs/configuration -```yaml -techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs -``` - -## Extending techdocs-backend - -Currently the build process of techdocs-backend is split up in these three stages. - -- Preparers -- Generators -- Publishers - -Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/backstage/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. - -Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. - -Publishers gets a folder path from the generator and publish it to your storage solution. Currently the only built in storage solution is a folder called `static/docs` inside the techdocs-backend plugin. - -Any of these can be extended. If we want to publish to a external static file server using rsync for example that can be done by creating a rsync publisher. _(Keep in mind that if you want techdocs-backend to initiate a build this would also require techdocs-backend to act as a proxy, which is not yet implemented.)_ +The techdocs-backend re-exports the [techdocs-common](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) package which has the features to prepare, generate and publish docs. +The Publishers are also used to fetch the static documentation files and render them in TechDocs. ## Links - [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/techdocs) -- [The Backstage homepage](https://backstage.io) +- [Backstage homepage](https://backstage.io) diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts new file mode 100644 index 0000000000..5a323f244a --- /dev/null +++ b/plugins/techdocs-backend/config.d.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. + */ + +/** + * 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 + * @see http://backstage.io/docs/features/techdocs/configuration + */ + techdocs: { + /** + * Documentation building process depends on the builder attr + */ + builder: 'local' | 'external'; + + /** + * Techdocs publisher information + */ + publisher: { + type: 'local' | 'googleGcs' | 'awsS3'; + }; + + /** + * @example 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 773b5a5d5c..03bea5233d 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.3.1", + "version": "0.6.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/techdocs-backend" + }, + "keywords": [ + "backstage", + "techdocs" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,29 +30,27 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.3", - "@backstage/catalog-model": "^0.4.0", - "@backstage/config": "^0.1.1", - "@types/dockerode": "^2.5.34", + "@backstage/backend-common": "^0.5.4", + "@backstage/catalog-model": "^0.7.2", + "@backstage/config": "^0.1.3", + "@backstage/techdocs-common": "^0.4.2", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "command-exists-promise": "^2.0.2", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.0", - "js-yaml": "^3.14.0", "knex": "^0.21.6", - "mock-fs": "^4.13.0", - "nodegit": "^0.27.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.0", + "@backstage/cli": "^0.6.2", "supertest": "^4.0.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts new file mode 100644 index 0000000000..54ea860fb9 --- /dev/null +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BuildMetadataStorage } from './BuildMetadataStorage'; + +describe('BuildMetadataStorage', () => { + it('should return build timestamp', () => { + const newMetadataStorage = new BuildMetadataStorage('entityID123abc'); + newMetadataStorage.setEtag('etag123abc'); + + const timestamp = newMetadataStorage.getEtag(); + + expect(timestamp).toBe('etag123abc'); + }); +}); diff --git a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts new file mode 100644 index 0000000000..a1bb918fae --- /dev/null +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts @@ -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. + */ +type buildInfo = { + // Entity uid: etag + [key: string]: string; +}; + +// TODO: Build info should be part of TechDocs storage, inside `techdocs_metadata.json` +// instead of in-memory storage of the Backstage instance. +// In case of multi-region Backstage deployments, or even using multiple Kubernetes pods, +// if each instance creates its separate build info in-memory, it will result in duplicate +// builds per instance. Also if the pod restarts, all the sites will have to be re-built. +const builds = {} as buildInfo; + +/** + * Store timestamps of the most recent TechDocs build of each Entity. This is + * used to invalidate cache if the latest commit in the documentation source + * repository is later than the timestamp. + */ +export class BuildMetadataStorage { + public entityUid: string; + private builds: buildInfo; + + constructor(entityUid: string) { + this.entityUid = entityUid; + this.builds = builds; + } + + setEtag(etag: string): void { + this.builds[this.entityUid] = etag; + } + + getEtag(): string | undefined { + return this.builds[this.entityUid]; + } +} diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts new file mode 100644 index 0000000000..51f4d212ba --- /dev/null +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NotModifiedError } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { + GeneratorBase, + GeneratorBuilder, + getLocationForEntity, + PreparerBase, + PreparerBuilder, + PublisherBase, + UrlPreparer, +} from '@backstage/techdocs-common'; +import Docker from 'dockerode'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { Logger } from 'winston'; +import { BuildMetadataStorage } from '.'; + +const getEntityId = (entity: Entity) => { + return `${entity.kind}:${entity.metadata.namespace ?? ''}:${ + entity.metadata.name + }`; +}; + +type DocsBuilderArguments = { + preparers: PreparerBuilder; + generators: GeneratorBuilder; + publisher: PublisherBase; + entity: Entity; + logger: Logger; + dockerClient: Docker; +}; + +export class DocsBuilder { + private preparer: PreparerBase; + private generator: GeneratorBase; + private publisher: PublisherBase; + private entity: Entity; + private logger: Logger; + private dockerClient: Docker; + + constructor({ + preparers, + generators, + publisher, + entity, + logger, + dockerClient, + }: DocsBuilderArguments) { + this.preparer = preparers.get(entity); + this.generator = generators.get(entity); + this.publisher = publisher; + this.entity = entity; + this.logger = logger; + this.dockerClient = dockerClient; + } + + public async build(): Promise { + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } + + /** + * Prepare and cache check + */ + + // Use the in-memory storage for setting and getting etag for this entity. + const buildMetadataStorage = new BuildMetadataStorage( + this.entity.metadata.uid, + ); + + // TODO: As of now, this happens on each and every request to TechDocs. + // In a high traffic environment, this will cause a lot of requests to the source code provider. + // After Async build is implemented https://github.com/backstage/backstage/issues/3717, + // make sure to limit checking for cache invalidation to once per minute or so. + let preparedDir: string; + let etag: string; + try { + const preparerResponse = await this.preparer.prepare(this.entity, { + etag: buildMetadataStorage.getEtag(), + }); + + preparedDir = preparerResponse.preparedDir; + etag = preparerResponse.etag; + } catch (err) { + if (err instanceof NotModifiedError) { + // No need to prepare anymore since cache is valid. + return; + } + throw new Error(err.message); + } + + this.logger.info( + `TechDocs prepare step completed for entity ${getEntityId(this.entity)}.`, + ); + this.logger.debug(`Prepared files temporarily stored at ${preparedDir}`); + + /** + * Generate + */ + + this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); + // 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-'), + ); + const parsedLocationAnnotation = getLocationForEntity(this.entity); + await this.generator.run({ + inputDir: preparedDir, + outputDir, + dockerClient: this.dockerClient, + parsedLocationAnnotation, + etag, + }); + + this.logger.debug(`Generated files temporarily stored at ${outputDir}`); + // Remove Prepared directory since it is no longer needed. + // Caveat: Can not remove prepared directory in case of git preparer since the + // local git repository is used to get etag on subsequent requests. + if (this.preparer instanceof UrlPreparer) { + this.logger.debug( + `Removing prepared directory ${preparedDir} since the site has been generated.`, + ); + try { + // Not a blocker hence no need to await this. + fs.remove(preparedDir); + } catch (error) { + this.logger.debug(`Error removing prepared directory ${error.message}`); + } + } + + /** + * Publish + */ + + this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); + await this.publisher.publish({ + entity: this.entity, + directory: outputDir, + }); + this.logger.debug( + `Removing generated directory ${outputDir} since the site has been published`, + ); + try { + // Not a blocker hence no need to await this. + fs.remove(outputDir); + } catch (error) { + this.logger.debug(`Error removing generated directory ${error.message}`); + } + + // Store the latest build etag for the entity + new BuildMetadataStorage(this.entity.metadata.uid).setEtag(etag); + } +} diff --git a/plugins/techdocs-backend/src/storage/index.ts b/plugins/techdocs-backend/src/DocsBuilder/index.ts similarity index 95% rename from plugins/techdocs-backend/src/storage/index.ts rename to plugins/techdocs-backend/src/DocsBuilder/index.ts index 3d37f69679..1380e24e7c 100644 --- a/plugins/techdocs-backend/src/storage/index.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './BuildMetadataStorage'; +export * from './builder'; diff --git a/plugins/techdocs-backend/src/git-auth.ts b/plugins/techdocs-backend/src/git-auth.ts deleted file mode 100644 index b580a6f05b..0000000000 --- a/plugins/techdocs-backend/src/git-auth.ts +++ /dev/null @@ -1,120 +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 parseGitUrl from 'git-url-parse'; -import { Config } from '@backstage/config'; -import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; - -export function getGitHost(url: string): string { - const { resource } = parseGitUrl(url); - return resource; -} - -export function getGitRepoType(url: string): string { - const typeMapping = [ - { url: /github*/g, type: 'github' }, - { url: /gitlab*/g, type: 'gitlab' }, - { url: /azure*/g, type: 'azure/api' }, - ]; - - const type = typeMapping.filter(item => item.url.test(url))[0]?.type; - - return type; -} - -export function getGithubHostToken( - config: Config, - host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.github') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, - ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.github.privateToken') ?? - config.getOptionalString('catalog.processors.githubApi.privateToken') ?? - process.env.GITHUB_TOKEN; - - return token; -} - -export function getGitlabHostToken( - config: Config, - host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.gitlab') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, - ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.gitlab.privateToken') ?? - config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? - process.env.GITLAB_TOKEN; - - return token; -} - -export function getAzureHostToken( - config: Config, - host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.azure') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, - ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.azureApi.privateToken') ?? - process.env.AZURE_TOKEN; - - return token; -} - -export const getTokenForGitRepo = async ( - repositoryUrl: string, -): Promise => { - // TODO(Rugvip): Config should not be loaded here, pass it in instead - const config = await loadBackendConfig({ - logger: getRootLogger(), - argv: process.argv, - }); - - const host = getGitHost(repositoryUrl); - const type = getGitRepoType(repositoryUrl); - - try { - switch (type) { - case 'github': - return getGithubHostToken(config, host); - case 'gitlab': - return getGitlabHostToken(config, host); - case 'azure/api': - return getAzureHostToken(config, host); - - default: - throw new Error('Failed to get repository type'); - } - } catch (error) { - throw error; - } -}; diff --git a/plugins/techdocs-backend/src/helpers.test.ts b/plugins/techdocs-backend/src/helpers.test.ts deleted file mode 100644 index 10518df74f..0000000000 --- a/plugins/techdocs-backend/src/helpers.test.ts +++ /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 { Readable } from 'stream'; -import { getDocFilesFromRepository } from './helpers'; -import { UrlReader, ReadTreeResponse } from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; - -describe('getDocFilesFromRepository', () => { - it('should read a remote directory using UrlReader.readTree', async () => { - class MockUrlReader implements UrlReader { - async read() { - return Buffer.from('mock'); - } - - async readTree(): Promise { - return { - dir: async () => { - return '/tmp/testfolder'; - }, - files: async () => { - return []; - }, - archive: async () => { - return Readable.from(''); - }, - }; - } - } - - const mockEntity: Entity = { - metadata: { - namespace: 'default', - annotations: { - 'backstage.io/techdocs-ref': - 'url:https://github.com/backstage/backstage/blob/master/subfolder/', - }, - name: 'mytestcomponent', - description: 'A component for testing', - }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - spec: { - type: 'documentation', - lifecycle: 'experimental', - owner: 'testuser', - }, - }; - - const output = await getDocFilesFromRepository( - new MockUrlReader(), - mockEntity, - ); - - expect(output).toBe('/tmp/testfolder'); - }); -}); diff --git a/plugins/techdocs-backend/src/index.ts b/plugins/techdocs-backend/src/index.ts index 8c65708017..5c57882788 100644 --- a/plugins/techdocs-backend/src/index.ts +++ b/plugins/techdocs-backend/src/index.ts @@ -15,4 +15,4 @@ */ export * from './service/router'; -export * from './techdocs'; +export * from '@backstage/techdocs-common'; diff --git a/plugins/techdocs-backend/src/service/helpers.test.ts b/plugins/techdocs-backend/src/service/helpers.test.ts new file mode 100644 index 0000000000..507373107d --- /dev/null +++ b/plugins/techdocs-backend/src/service/helpers.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getEntityNameFromUrlPath } from './helpers'; + +describe('getEntityNameFromUrlPath', () => { + it('should parse correctly', () => { + const path = 'default/Component/documented-component'; + const parsedEntity = getEntityNameFromUrlPath(path); + expect(parsedEntity).toHaveProperty('namespace', 'default'); + expect(parsedEntity).toHaveProperty('kind', 'Component'); + expect(parsedEntity).toHaveProperty('name', 'documented-component'); + }); +}); diff --git a/plugins/techdocs-backend/src/service/helpers.ts b/plugins/techdocs-backend/src/service/helpers.ts index 012061dc0a..fc80ab717d 100644 --- a/plugins/techdocs-backend/src/service/helpers.ts +++ b/plugins/techdocs-backend/src/service/helpers.ts @@ -13,126 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Docker from 'dockerode'; -import { Logger } from 'winston'; -import { Entity } from '@backstage/catalog-model'; -import { - PreparerBuilder, - PublisherBase, - GeneratorBuilder, - PreparerBase, - GeneratorBase, -} from '../techdocs'; -import { BuildMetadataStorage } from '../storage'; -import { getLocationForEntity, getLastCommitTimestamp } from '../helpers'; +import { EntityName } from '@backstage/catalog-model'; +/** + * Using the path of the TechDocs page URL, return a structured EntityName type object with namespace, + * kind and name of the Entity. + * @param {string} path Example: default/Component/documented-component + */ +export const getEntityNameFromUrlPath = (path: string): EntityName => { + const [namespace, kind, name] = path.split('/'); -const getEntityId = (entity: Entity) => { - return `${entity.kind}:${entity.metadata.namespace ?? ''}:${ - entity.metadata.name - }`; + return { + namespace, + kind, + name, + }; }; - -type DocsBuilderArguments = { - preparers: PreparerBuilder; - generators: GeneratorBuilder; - publisher: PublisherBase; - entity: Entity; - logger: Logger; - dockerClient: Docker; -}; - -export class DocsBuilder { - private preparer: PreparerBase; - private generator: GeneratorBase; - private publisher: PublisherBase; - private entity: Entity; - private logger: Logger; - private dockerClient: Docker; - - constructor({ - preparers, - generators, - publisher, - entity, - logger, - dockerClient, - }: DocsBuilderArguments) { - this.preparer = preparers.get(entity); - this.generator = generators.get(entity); - this.publisher = publisher; - this.entity = entity; - this.logger = logger; - this.dockerClient = dockerClient; - } - - public async build() { - this.logger.info(`Running preparer on entity ${getEntityId(this.entity)}`); - const preparedDir = await this.preparer.prepare(this.entity); - - const parsedLocationAnnotation = getLocationForEntity(this.entity); - - this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); - const { resultDir } = await this.generator.run({ - directory: preparedDir, - dockerClient: this.dockerClient, - parsedLocationAnnotation, - }); - - this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); - await this.publisher.publish({ - entity: this.entity, - directory: resultDir, - }); - - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - new BuildMetadataStorage(this.entity.metadata.uid).storeBuildTimestamp(); - } - - public async docsUpToDate() { - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - const buildMetadataStorage = new BuildMetadataStorage( - this.entity.metadata.uid, - ); - const { type, target } = getLocationForEntity(this.entity); - - // Unless docs are stored locally - const nonAgeCheckTypes = ['dir', 'file', 'url']; - if (!nonAgeCheckTypes.includes(type)) { - const lastCommit = await getLastCommitTimestamp(target, this.logger); - const storageTimeStamp = buildMetadataStorage.getTimestamp(); - - // Check if documentation source is newer than what we have - if (storageTimeStamp && storageTimeStamp >= lastCommit) { - this.logger.debug( - `Docs for entity ${getEntityId(this.entity)} is up to date.`, - ); - return true; - } - } - - // TODO: Better caching for URL. - 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.`, - ); - return false; - } -} diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 625252dd7e..7f6ff60fdd 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -13,26 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; -import Router from 'express-promise-router'; -import express from 'express'; -import Knex from 'knex'; -import fetch from 'cross-fetch'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import Docker from 'dockerode'; import { GeneratorBuilder, + getLocationForEntity, PreparerBuilder, PublisherBase, - LocalPublish, -} from '../techdocs'; -import { - PluginEndpointDiscovery, - resolvePackagePath, -} from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; -import { DocsBuilder } from './helpers'; -import { getLocationForEntity } from '../helpers'; +} from '@backstage/techdocs-common'; +import fetch from 'cross-fetch'; +import Docker from 'dockerode'; +import express from 'express'; +import Router from 'express-promise-router'; +import Knex from 'knex'; +import { Logger } from 'winston'; +import { DocsBuilder } from '../DocsBuilder'; +import { getEntityNameFromUrlPath } from './helpers'; type RouterOptions = { preparers: PreparerBuilder; @@ -45,11 +42,6 @@ type RouterOptions = { dockerClient: Docker; }; -const staticDocsDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', -); - export async function createRouter({ preparers, generators, @@ -62,23 +54,25 @@ export async function createRouter({ const router = Router(); router.get('/metadata/techdocs/*', async (req, res) => { - let storageUrl = config.getString('techdocs.storageUrl'); - if (publisher instanceof LocalPublish) { - storageUrl = new URL( - new URL(storageUrl).pathname, - await discovery.getBaseUrl('techdocs'), - ).toString(); - } + // path is `:namespace/:kind:/:name` const { '0': path } = req.params; - - const metadataURL = `${storageUrl}/${path}/techdocs_metadata.json`; + const entityName = getEntityNameFromUrlPath(path); try { - const techdocsMetadata = await (await fetch(metadataURL)).json(); - res.send(techdocsMetadata); + const techdocsMetadata = await publisher.fetchTechDocsMetadata( + entityName, + ); + + res.json(techdocsMetadata); } catch (err) { - logger.info(`Unable to get metadata for ${path} with error ${err}`); - throw new Error(`Unable to get metadata for ${path} with error ${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}`, + ); } }); @@ -88,14 +82,18 @@ export async function createRouter({ const { kind, namespace, name } = req.params; try { + const token = getBearerToken(req.headers.authorization); const entity = (await ( await fetch( `${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ) ).json()) as Entity; const locationMetadata = getLocationForEntity(entity); - res.send({ ...entity, locationMetadata }); + res.json({ ...entity, locationMetadata }); } catch (err) { logger.info( `Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, @@ -107,14 +105,18 @@ export async function createRouter({ }); router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { - const storageUrl = config.getString('techdocs.storageUrl'); - const { kind, namespace, name } = req.params; + const storageUrl = + config.getOptionalString('techdocs.storageUrl') ?? + `${await discovery.getExternalBaseUrl('techdocs')}/static/docs`; const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); - const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`); + const token = getBearerToken(req.headers.authorization); + const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!catalogRes.ok) { const catalogResText = await catalogRes.text(); res.status(catalogRes.status); @@ -124,25 +126,90 @@ export async function createRouter({ const entity: Entity = await catalogRes.json(); - const docsBuilder = new DocsBuilder({ - preparers, - generators, - publisher, - dockerClient, - logger, - entity, - }); + let publisherType = ''; + try { + publisherType = config.getString('techdocs.publisher.type'); + } catch (err) { + throw new Error( + 'Unable to get techdocs.publisher.type in your app config. Set it to either ' + + "'local', 'googleGcs' or other support storage providers. Read more here " + + 'https://backstage.io/docs/features/techdocs/architecture', + ); + } - if (!(await docsBuilder.docsUpToDate())) { - await docsBuilder.build(); + // techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to 'local' + // If set to 'external', it will only try to fetch and assume that an external process (e.g. CI/CD pipeline + // of the repository) is responsible for building and publishing documentation to the storage provider. + if (config.getString('techdocs.builder') === 'local') { + const docsBuilder = new DocsBuilder({ + preparers, + generators, + publisher, + dockerClient, + logger, + entity, + }); + switch (publisherType) { + case 'local': + await docsBuilder.build(); + 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. + if (!(await publisher.hasDocsBeenGenerated(entity))) { + logger.info( + 'No pre-generated documentation files found for the entity in the storage. Building docs...', + ); + await docsBuilder.build(); + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched + // on the user's page. If not, respond with a message asking them to check back later. + // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second + let foundDocs = false; + for (let attempt = 0; attempt < 5; attempt++) { + if (await publisher.hasDocsBeenGenerated(entity)) { + foundDocs = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!foundDocs) { + logger.error( + 'Published files are taking longer to show up in storage. Something went wrong.', + ); + res + .status(408) + .send( + 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + ); + return; + } + } else { + logger.info( + 'Found pre-generated docs for this entity. Serving them.', + ); + // TODO: re-trigger build for cache invalidation. + // Add build info in techdocs_metadata.json and compare it against + // the etag/commit in the repository. + // Without this, docs will not be re-built once they have been generated. + // Although it is unconventional that anyone will face this issue - because + // if you have an external storage, you should be using CI/CD to build and publish docs. + } + break; + default: + } } res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); }); - if (publisher instanceof LocalPublish) { - router.use('/static/docs', express.static(staticDocsDir)); - } + // Route middleware which serves files from the storage set in the publisher. + router.use('/static/docs', publisher.docsRouter()); return router; } + +function getBearerToken(header?: string): string | undefined { + return header?.match(/(?:Bearer)\s+(\S+)/i)?.[1]; +} diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 4082a36172..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'; @@ -27,8 +28,8 @@ import { DirectoryPreparer, Generators, TechdocsGenerator, - LocalPublish, -} from '../techdocs'; + Publisher, +} from '@backstage/techdocs-common'; import { ConfigReader } from '@backstage/config'; export interface ServerOptions { @@ -41,19 +42,34 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'techdocs-backend' }); - const config = ConfigReader.fromConfigs([]); + const config = new ConfigReader({ + techdocs: { + publisher: { + type: 'local', + }, + }, + }); 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(logger); + const directoryPreparer = new DirectoryPreparer( + config, + logger, + mockUrlReader, + ); preparers.register('dir', directoryPreparer); const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); generators.register('techdocs', techdocsGenerator); - const publisher = new LocalPublish(logger, discovery); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); const dockerClient = new Docker(); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts deleted file mode 100644 index d9ba96a031..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts +++ /dev/null @@ -1,50 +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 path from 'path'; -import { Entity } from '@backstage/catalog-model'; -import { PreparerBase } from './types'; -import parseGitUrl from 'git-url-parse'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; - -import { Logger } from 'winston'; - -export class CommonGitPreparer implements PreparerBase { - private readonly logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async prepare(entity: Entity): Promise { - const { target } = parseReferenceAnnotation( - 'backstage.io/techdocs-ref', - entity, - ); - - try { - const repoPath = await checkoutGitRepository(target, this.logger); - const parsedGitLocation = parseGitUrl(target); - - return path.join(repoPath, parsedGitLocation.filepath); - } catch (error) { - this.logger.debug(`Repo checkout failed with error ${error.message}`); - throw error; - } - } -} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts deleted file mode 100644 index 1effcf61b5..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts +++ /dev/null @@ -1,76 +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 { PreparerBase } from './types'; -import { Entity } from '@backstage/catalog-model'; -import path from 'path'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; -import { InputError } from '@backstage/backend-common'; -import parseGitUrl from 'git-url-parse'; -import { Logger } from 'winston'; - -export class DirectoryPreparer implements PreparerBase { - private readonly logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - private async resolveManagedByLocationToDir(entity: Entity) { - const { type, target } = parseReferenceAnnotation( - 'backstage.io/managed-by-location', - entity, - ); - - this.logger.debug( - `Building docs for entity with type 'dir' and managed-by-location '${type}'`, - ); - switch (type) { - case 'github': - case 'gitlab': - case 'azure/api': { - const parsedGitLocation = parseGitUrl(target); - const repoLocation = await checkoutGitRepository(target, this.logger); - - return path.dirname( - path.join(repoLocation, parsedGitLocation.filepath), - ); - } - - case 'file': - return path.dirname(target); - default: - throw new InputError(`Unable to resolve location type ${type}`); - } - } - - async prepare(entity: Entity): Promise { - const { target } = parseReferenceAnnotation( - 'backstage.io/techdocs-ref', - entity, - ); - - const managedByLocationDirectory = await this.resolveManagedByLocationToDir( - entity, - ); - - return new Promise(resolve => { - resolve(path.resolve(managedByLocationDirectory, target)); - }); - } -} diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts deleted file mode 100644 index 0029ea5c4e..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { LocalPublish } from './local'; -export type { PublisherBase } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts deleted file mode 100644 index 464c4b90a0..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import { Logger } from 'winston'; -import { Entity } from '@backstage/catalog-model'; -import { PublisherBase } from './types'; -import { - resolvePackagePath, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; - -export class LocalPublish implements PublisherBase { - private readonly logger: Logger; - private readonly discovery: PluginEndpointDiscovery; - - constructor(logger: Logger, discovery: PluginEndpointDiscovery) { - this.logger = logger; - this.discovery = discovery; - } - - publish({ - entity, - directory, - }: { - entity: Entity; - directory: string; - }): - | Promise<{ - remoteUrl: string; - }> - | { remoteUrl: string } { - const entityNamespace = entity.metadata.namespace ?? 'default'; - - const publishDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', - entityNamespace, - entity.kind, - entity.metadata.name, - ); - - if (!fs.existsSync(publishDir)) { - this.logger.info(`Could not find ${publishDir}, creating the directory.`); - fs.mkdirSync(publishDir, { recursive: true }); - } - - return new Promise((resolve, reject) => { - fs.copy(directory, publishDir, err => { - if (err) { - this.logger.debug( - `Failed to copy docs from ${directory} to ${publishDir}`, - ); - reject(err); - } - - this.discovery - .getBaseUrl('techdocs') - .then(techdocsApiUrl => { - resolve({ - remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, - }); - }) - .catch(reason => { - reject(reason); - }); - }); - }); - } -} diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index b3f1507f9f..a1e7e456d4 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,276 @@ # @backstage/plugin-techdocs +## 0.5.8 + +### Patch Changes + +- f37992797: Got rid of some `attr` and cleaned up a bit in the TechDocs config schema. +- 2499f6cde: Add support for assuming role in AWS integrations +- Updated dependencies [3a58084b6] +- Updated dependencies [e799e74d4] +- Updated dependencies [dc12852c9] +- Updated dependencies [d0760ecdf] +- Updated dependencies [1407b34c6] +- Updated dependencies [88f1f1b60] +- Updated dependencies [bad21a085] +- Updated dependencies [9615e68fb] +- Updated dependencies [49f9b7346] +- Updated dependencies [5c2e2863f] +- Updated dependencies [3a58084b6] +- Updated dependencies [2499f6cde] +- Updated dependencies [a1f5e6545] +- Updated dependencies [1e4ddd71d] +- Updated dependencies [2c1f2a7c2] + - @backstage/core@0.6.3 + - @backstage/test-utils@0.1.8 + - @backstage/plugin-catalog-react@0.1.0 + - @backstage/catalog-model@0.7.2 + - @backstage/techdocs-common@0.4.2 + - @backstage/config@0.1.3 + +## 0.5.7 + +### Patch Changes + +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [fb28da212] +- Updated dependencies [d34d26125] +- Updated dependencies [0af242b6d] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [10a0124e0] +- Updated dependencies [07e226872] +- Updated dependencies [26e143e60] +- Updated dependencies [c6655413d] +- Updated dependencies [44414239f] +- Updated dependencies [b0a41c707] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + - @backstage/techdocs-common@0.4.1 + - @backstage/plugin-catalog-react@0.0.4 + +## 0.5.6 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- 41af18227: Migrated to new composability API, exporting the plugin instance as `techdocsPlugin`, the top-level page as `TechdocsPage`, and the entity content as `EntityTechdocsContent`. +- 8f3443427: Enhance API calls to support trapping 500 errors from techdocs-backend +- Updated dependencies [77ad0003a] +- Updated dependencies [b51ee6ece] +- Updated dependencies [19d354c78] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] +- Updated dependencies [b51ee6ece] + - @backstage/techdocs-common@0.4.0 + - @backstage/test-utils@0.1.7 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + +## 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 + +- 359f9d2d8: Added configuration schema for the commonly used properties of techdocs and techdocs-backend plugins +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [8804e8981] +- Updated dependencies [87c0c53c2] +- Updated dependencies [86c3c652a] +- Updated dependencies [27f2af935] + - @backstage/core-api@0.2.8 + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + - @backstage/techdocs-common@0.3.1 + +## 0.5.1 + +### Patch Changes + +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [1dc445e89] +- Updated dependencies [a8573e53b] + - @backstage/core-api@0.2.7 + - @backstage/core@0.4.2 + - @backstage/test-utils@0.1.6 + - @backstage/plugin-catalog@0.2.8 + - @backstage/techdocs-common@0.3.0 + +## 0.5.0 + +### Minor Changes + +- dae4f3983: _Breaking changes_ + + 1. Added option to use Google Cloud Storage as a choice to store the static generated files for TechDocs. + It can be configured using `techdocs.publisher.type` option in `app-config.yaml`. + Step-by-step guide to configure GCS is available here https://backstage.io/docs/features/techdocs/using-cloud-storage + Set `techdocs.publisher.type` to `'local'` if you want to continue using local filesystem to store TechDocs files. + + 2. `techdocs.builder` is now required and can be set to `'local'` or `'external'`. (Set it to `'local'` for now, since CI/CD build + workflow for TechDocs will be available soon (in few weeks)). + 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. + If builder is set to `'external'`, `techdocs-backend` will only fetch the docs and will NOT try to generate and publish. In this case of `'external'`, + we assume that docs are being built in the CI/CD pipeline of the repository. + TechDocs will not assume a default value for `techdocs.builder`. It is better to explicitly define it in the `app-config.yaml`. + + 3. When configuring TechDocs in your backend, there is a difference in how a new publisher is created. + + ``` + --- const publisher = new LocalPublish(logger, discovery); + +++ const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. + + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + + 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs + to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - + app should only import `@backstage/plugin-techdocs` and `@backstage/plugin-techdocs-backend`. + + _Patch changes_ + + 1. See all of TechDocs config options and its documentation https://backstage.io/docs/features/techdocs/configuration + + 2. Logic about serving static files and metadata retrieval have been abstracted away from the router in `techdocs-backend` to the instance of publisher. + + 3. Removed Material UI Spinner from TechDocs header. Spinners cause unnecessary UX distraction. + Case 1 (when docs are built and are to be served): Spinners appear for a split second before the name of site shows up. This unnecessarily distracts eyes because spinners increase the size of the Header. A dot (.) would do fine. Definitely more can be done. + Case 2 (when docs are being generated): There is already a linear progress bar (which is recommended in Storybook). + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [dae4f3983] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [7dd2ef7d1] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/techdocs-common@0.2.0 + - @backstage/core@0.4.1 + - @backstage/core-api@0.2.6 + - @backstage/plugin-catalog@0.2.7 + +## 0.4.0 + +### Minor Changes + +- 87a33d2fe: Removed modifyCss transformer and moved the css to injectCss transformer + Fixed issue where some internal doc links would cause a reload of the page + +### Patch Changes + +- Updated dependencies [b6557c098] +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [d8d5a17da] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core-api@0.2.5 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/test-utils@0.1.5 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + ## 0.3.1 ### Patch Changes diff --git a/plugins/techdocs/README.md b/plugins/techdocs/README.md index 2e9365f6c6..c5e8fa6887 100644 --- a/plugins/techdocs/README.md +++ b/plugins/techdocs/README.md @@ -6,9 +6,7 @@ Set up Backstage and TechDocs by follow our guide on [Getting Started](../../doc ## Configuration -### Custom Storage URL - -TechDocs will try to read your documentation from the URL you have specified in the `techdocs storageUrl` in `app-config.yml`. +http://backstage.io/docs/features/techdocs/configuration ### TechDocs Storage Api diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts new file mode 100644 index 0000000000..1c49833d68 --- /dev/null +++ b/plugins/techdocs/config.d.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** + * Configuration options for the techdocs plugin + * @see http://backstage.io/docs/features/techdocs/configuration + */ + techdocs: { + /** + * Documentation building process depends on the builder attr + * @visibility frontend + */ + builder: 'local' | 'external'; + + /** + * Techdocs generator information + */ + generators?: { + techdocs: 'local' | 'docker'; + }; + + /** + * Techdocs publisher information + */ + publisher?: + | { + type: 'local'; + } + | { + type: 'awsS3'; + + /** + * Required when 'type' is set to awsS3 + */ + awsS3?: { + /** + * (Optional) Credentials used to access a storage bucket. + * If not set, environment variables or aws config file will be used to authenticate. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + * @visibility secret + */ + credentials?: { + /** + * User access key id + * @visibility secret + */ + accessKeyId: string; + /** + * User secret access key + * @visibility secret + */ + secretAccessKey: string; + /** + * ARN of role to be assumed + * @visibility backend + */ + roleArn?: string; + }; + /** + * (Required) Cloud Storage Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) AWS Region. + * If not set, AWS_REGION environment variable or aws config file will be used. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + * @visibility secret + */ + region?: string; + }; + } + | { + type: 'azureBlobStorage'; + + /** + * Required when 'type' is set to azureBlobStorage + */ + azureBlobStorage?: { + /** + * (Required) Credentials used to access a storage container. + * @visibility secret + */ + credentials: { + /** + * Account access name + * @visibility secret + */ + accountName: string; + /** + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * @see https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * @visibility secret + */ + accountKey?: string; + }; + /** + * (Required) Cloud Storage Container Name + * @visibility backend + */ + containerName: string; + }; + } + | { + type: 'googleGcs'; + + /** + * Required when 'type' is set to googleGcs + */ + googleGcs?: { + /** + * (Required) Cloud Storage Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) API key used to write to a storage bucket. + * If not set, environment variables will be used to authenticate. + * @see https://cloud.google.com/docs/authentication/production + * @visibility secret + */ + credentials?: string; + }; + }; + + /** + * @example http://localhost:7000/api/techdocs + * @visibility frontend + * @deprecated + */ + requestUrl?: string; + + /** + * @example 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..5fa494a973 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,23 +13,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DiscoveryApi, IdentityApi } 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; + public identityApi: IdentityApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + 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 token = await this.identityApi.getIdToken(); const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ); if (request.status === 404) { @@ -39,8 +65,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..3eefa0a814 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,19 +14,26 @@ * limitations under the License. */ +import { configApiRef, discoveryApiRef, identityApiRef } 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, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsDevStorageApi({ - apiOrigin: 'http://localhost:3000/api', + configApi, + discoveryApi, + identityApi, }), }) - .registerPlugin(plugin) + .registerPlugin(techdocsPlugin) .render(); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index d3cd6cdf64..697ab6521b 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.3.1", + "version": "0.5.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,16 @@ "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/techdocs" + }, + "keywords": [ + "backstage", + "techdocs" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,12 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.4.0", - "@backstage/core": "^0.3.2", - "@backstage/core-api": "^0.2.4", - "@backstage/plugin-catalog": "^0.2.5", - "@backstage/test-utils": "^0.1.4", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.3", + "@backstage/catalog-model": "^0.7.2", + "@backstage/core": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/test-utils": "^0.1.8", + "@backstage/theme": "^0.2.3", + "@backstage/techdocs-common": "^0.4.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,14 +47,14 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "sanitize-html": "^1.27.0" + "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", @@ -52,28 +63,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/techdocs", - "type": "object", - "properties": { - "techdocs": { - "type": "object", - "properties": { - "requestUrl": { - "type": "string", - "visibility": "frontend" - }, - "storageUrl": { - "type": "string" - } - }, - "required": [ - "requestUrl" - ] - } - } - } + "configSchema": "config.d.ts" } 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..6de94b63bb 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, IdentityApi } 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,29 @@ 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; + public identityApi: IdentityApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -61,9 +86,13 @@ 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 token = await this.identityApi.getIdToken(); - const request = await fetch(`${requestUrl}`); + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); const res = await request.json(); return res; @@ -80,9 +109,13 @@ 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 token = await this.identityApi.getIdToken(); - const request = await fetch(`${requestUrl}`); + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); const res = await request.json(); return res; @@ -95,10 +128,29 @@ 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; + public identityApi: IdentityApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -112,31 +164,51 @@ 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 token = await this.identityApi.getIdToken(); const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ); - 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..a945ca1494 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -34,6 +34,9 @@ import { createRouteRef, createApiFactory, configApiRef, + discoveryApiRef, + identityApiRef, + createRoutableExtension, } from '@backstage/core'; import { techdocsStorageApiRef, @@ -57,25 +60,54 @@ 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, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsStorageApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, + identityApi, }), }), createApiFactory({ api: techdocsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, + identityApi, }), }), ], + 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 bdf91936ba..c651f1ec13 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -30,7 +30,6 @@ import transformer, { addLinkClickListener, removeMkdocsHeader, simplifyMkdocsFooter, - modifyCss, onCssReady, sanitizeDOM, injectCss, @@ -71,15 +70,6 @@ export const Reader = ({ entityId, onReady }: Props) => { path, }), rewriteDocLinks(), - modifyCss({ - cssTransforms: { - '.md-main__inner': [{ 'margin-top': '0' }], - '.md-sidebar': [{ top: '0' }, { width: '20rem' }], - '.md-typeset': [{ 'font-size': '1rem' }], - '.md-nav': [{ 'font-size': '1rem' }], - '.md-grid': [{ 'max-width': '80vw' }], - }, - }), removeMkdocsHeader(), simplifyMkdocsFooter(), injectCss({ @@ -92,6 +82,11 @@ export const Reader = ({ entityId, onReady }: Props) => { --md-code-fg-color: ${theme.palette.text.primary}; --md-code-bg-color: ${theme.palette.background.paper}; } + .md-main__inner { margin-top: 0; } + .md-sidebar { top: 0; width: 20rem; } + .md-typeset { font-size: 1rem; } + .md-nav { font-size: 1rem; } + .md-grid { max-width: 80vw; } `, }), ]); @@ -120,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'); }, @@ -152,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 744242e343..04f2106161 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -15,18 +15,32 @@ */ import React from 'react'; -import { ErrorPage } from '@backstage/core'; +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', + ); + + let additionalInfo = ''; + if (techdocsBuilder !== 'local') { + additionalInfo = + "Note that techdocs.builder is not set to 'local' in your config, which means this Backstage app will not " + + "generate docs if they are not found. Make sure the project's docs are generated and published by some external " + + "process (e.g. CI/CD pipeline). Or change techdocs.builder to 'local' to generate docs from this Backstage " + + 'instance.'; + } + 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 d7a5a3c48f..c994b52422 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -16,16 +16,16 @@ import React from 'react'; import { AsyncState } from 'react-use/lib/useAsync'; -import { CircularProgress } from '@material-ui/core'; 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; }; }; @@ -86,7 +86,7 @@ export const TechDocsPageHeader = ({ return (
} + title={siteName ? siteName : '.'} pageTitleOverride={siteName || name} subtitle={ siteDescription && siteDescription !== 'None' ? siteDescription : '' 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/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index b6a6509550..7c5fd4d9f4 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -28,13 +28,13 @@ export const addLinkClickListener = ({ return dom => { Array.from(dom.getElementsByTagName('a')).forEach(elem => { elem.addEventListener('click', (e: MouseEvent) => { - const target = e.target as HTMLAnchorElement; - const href = target?.getAttribute('href'); + const target = elem as HTMLAnchorElement; + const href = target.getAttribute('href'); if (!href) return; if (href.startsWith(baseUrl)) { e.preventDefault(); - onClick(e, target.getAttribute('href')!); + onClick(e, href); } }); }); diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 0bab085abe..24f3976d75 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -19,7 +19,6 @@ export * from './rewriteDocLinks'; export * from './addLinkClickListener'; export * from './removeMkdocsHeader'; export * from './simplifyMkdocsFooter'; -export * from './modifyCss'; export * from './onCssReady'; export * from './sanitizeDOM'; export * from './injectCss'; diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx deleted file mode 100644 index fff3869c96..0000000000 --- a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx +++ /dev/null @@ -1,58 +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 { createTestShadowDom } from '../../test-utils'; -import { modifyCss } from '../transformers'; - -describe('modifyCss', () => { - it('does not modify css', () => { - const shadowDom = createTestShadowDom( - `
`, - { - preTransformers: [], - postTransformers: [], - }, - ); - - const { fontSize } = getComputedStyle( - shadowDom.querySelector('.md-typeset')!, - ); - - expect(fontSize).toBe('0.8em'); - }); - - it('does modify css', () => { - const shadowDom = createTestShadowDom( - `
`, - { - preTransformers: [ - modifyCss({ - cssTransforms: { - '.md-typeset': [{ 'font-size': '1em' }], - }, - }), - ], - postTransformers: [], - }, - ); - - const { fontSize } = getComputedStyle( - shadowDom.querySelector('.md-typeset')!, - ); - - expect(fontSize).toBe('1em'); - }); -}); diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.ts b/plugins/techdocs/src/reader/transformers/modifyCss.ts deleted file mode 100644 index 5116baac45..0000000000 --- a/plugins/techdocs/src/reader/transformers/modifyCss.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Transformer } from './index'; - -type ModifyCssOptions = { - // Example: { '.md-container': { 'marginTop': '10px' }} - cssTransforms: { [key: string]: { [key: string]: string }[] }; -}; - -export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => { - return dom => { - Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => { - const elementsToChange = Array.from( - dom.querySelectorAll(cssSelector), - ); - if (elementsToChange.length < 1) return; - - cssChanges.forEach(changes => { - elementsToChange.forEach((element: HTMLElement) => { - Object.entries(changes).forEach(([cssProperty, cssValue]) => { - element.style.setProperty(cssProperty, cssValue); - }); - }); - }); - }); - - return dom; - }; -}; 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 9efbc6eba7..d35b347e16 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/plugin-user-settings +## 0.2.6 + +### Patch Changes + +- d872f662d: Use routed tabs to link to every settings page. +- Updated dependencies [fd3f2a8c0] +- Updated dependencies [f4c2bcf54] +- Updated dependencies [07e226872] +- Updated dependencies [f62e7abe5] +- Updated dependencies [96f378d10] +- Updated dependencies [688b73110] + - @backstage/core@0.6.2 + +## 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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.2 ### 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 e1b94e85d9..547644c8bd 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.2", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "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/user-settings" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", @@ -32,11 +41,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx index 4399b6bd3e..072c90ddac 100644 --- a/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx +++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx @@ -37,18 +37,13 @@ const mockGoogleAuth = { }; const createConfig = () => - ConfigReader.fromConfigs([ - { - context: '', - data: { - auth: { - providers: { - google: { development: {} }, - }, - }, + new ConfigReader({ + auth: { + providers: { + google: { development: {} }, }, }, - ]); + }); const config = createConfig(); diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index a1dab1e7d4..415d1eaad6 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -49,7 +49,7 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => ( )} {configuredProviders.includes('github') && ( ( )} {configuredProviders.includes('gitlab') && ( ( - 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/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx index 0b4a86c99d..9fb576b200 100644 --- a/plugins/user-settings/src/components/SettingsPage.tsx +++ b/plugins/user-settings/src/components/SettingsPage.tsx @@ -14,39 +14,35 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Content, Header, HeaderTabs, Page } from '@backstage/core'; -import { General } from './General'; +import { Header, Page, TabbedLayout } from '@backstage/core'; +import React from 'react'; import { AuthProviders } from './AuthProviders'; import { FeatureFlags } from './FeatureFlags'; +import { General } from './General'; type Props = { providerSettings?: JSX.Element; }; export const SettingsPage = ({ providerSettings }: Props) => { - const [activeTab, setActiveTab] = useState(0); - const onTabChange = (index: number) => { - setActiveTab(index); - }; - - const tabs = [ - { id: 'general', label: 'General' }, - { id: 'auth-providers', label: 'Authentication Providers' }, - { id: 'feature-flags', label: 'Feature Flags' }, - ]; - - const content = [ - , - , - , - ]; - return (
- - {content[activeTab]} + + + + + + + + + + + + ); }; diff --git a/plugins/user-settings/src/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 b1111eb107..594d30226a 100644 --- a/plugins/welcome/CHANGELOG.md +++ b/plugins/welcome/CHANGELOG.md @@ -1,5 +1,41 @@ # @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 + +- Updated dependencies [2527628e1] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/theme@0.2.2 + ## 0.2.2 ### 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 fe1d2e1604..9ed7c219cf 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.2.2", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -10,6 +10,15 @@ "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/welcome" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", @@ -21,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.2", - "@backstage/theme": "^0.2.1", + "@backstage/core": "^0.6.3", + "@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", @@ -32,11 +41,11 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.0", - "@backstage/dev-utils": "^0.1.5", - "@backstage/test-utils": "^0.1.4", + "@backstage/cli": "^0.6.2", + "@backstage/dev-utils": "^0.1.12", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index b639da9d50..210cdcf921 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; -import WelcomePage from './WelcomePage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { ApiProvider, ApiRegistry, - errorApiRef, configApiRef, ConfigReader, + errorApiRef, } from '@backstage/core'; +import { renderInTestApp } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import React from 'react'; +import WelcomePage from './WelcomePage'; describe('WelcomePage', () => { - it('should render', () => { - // TODO: use common test app with mock implementations of all core APIs - const rendered = render( + it('should render', async () => { + const { baseElement } = await renderInTestApp( { , ); - expect(rendered.baseElement).toBeInTheDocument(); + expect(baseElement).toBeInTheDocument(); }); }); 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/check-type-dependencies.js b/scripts/check-type-dependencies.js index 6ee4626a21..f3862214a1 100755 --- a/scripts/check-type-dependencies.js +++ b/scripts/check-type-dependencies.js @@ -24,8 +24,8 @@ const chalk = require('chalk'); async function main() { // This is from lerna, and cba polluting root package.json // eslint-disable-next-line import/no-extraneous-dependencies - const LernaProject = require('@lerna/project'); - const project = new LernaProject(resolvePath('.')); + const { Project } = require('@lerna/project'); + const project = new Project(resolvePath('.')); const packages = await project.getPackages(); let hadErrors = false; diff --git a/scripts/create-github-release.js b/scripts/create-github-release.js new file mode 100755 index 0000000000..1d6a493db3 --- /dev/null +++ b/scripts/create-github-release.js @@ -0,0 +1,191 @@ +#!/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 creates a release on GitHub for the Backstage repository. + * Given a git tag, it identifies the PR created by changesets which is responsible for creating + * the git tag. It then uses the PR description consisting of changelogs for packages as the + * release description. + * + * Example: + * + * Set GITHUB_TOKEN environment variable. + * + * (Dry Run mode, will create a DRAFT release, but will not publish it.) + * (Draft releases are visible to maintainers and do not notify users.) + * $ node scripts/get-release-description v0.4.1 + * + * This will open the git tree at this tag https://github.com/backstage/backstage/tree/v0.4.1 + * It will identify https://github.com/backstage/backstage/pull/3668 as the responsible changeset PR. + * And will use everything in the PR description under "Releases" section. + * + * (Production or GitHub Actions Mode) + * $ node scripts/get-release-description v0.4.1 true + * + * This will do the same steps as above, and will publish the Release with the description. + */ + +const { Octokit } = require('@octokit/rest'); + +// See Examples above to learn about these command line arguments. +const [TAG_NAME, BOOL_CREATE_RELEASE] = process.argv.slice(2); + +if (!BOOL_CREATE_RELEASE) { + console.log( + '\nRunning script in Dry Run mode. It will output details, will create a draft release but will NOT publish it.', + ); +} + +const GH_OWNER = 'backstage'; +const GH_REPO = 'backstage'; +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({ + auth: process.env.GITHUB_TOKEN, +}); + +// Get the message of the commit responsible for a tag +async function getCommitMessageUsingTagName(tagName) { + // Get the tag SHA using the provided tag name + const refData = await octokit.git.getRef({ + owner: GH_OWNER, + repo: GH_REPO, + ref: `tags/${tagName}`, + }); + if (refData.status !== 200) { + console.error('refData:'); + console.error(refData); + throw new Error( + 'Something went wrong when getting the tag SHA using tag name', + ); + } + const tagSha = refData.data.object.sha; + console.log(`SHA for the tag ${TAG_NAME} is ${tagSha}`); + + // Get the commit SHA using the tag SHA + const tagData = await octokit.git.getTag({ + owner: GH_REPO, + repo: GH_REPO, + tag_sha: tagSha, + }); + if (tagData.status !== 200) { + console.error('tagData:'); + console.error(tagData); + throw new Error( + 'Something went wrong when getting the commit SHA using tag SHA', + ); + } + const commitSha = tagData.data.object.sha; + console.log( + `The commit for the tag is https://github.com/backstage/backstage/commit/${commitSha}`, + ); + + // Get the commit message using the commit SHA + const commitData = await octokit.git.getCommit({ + owner: GH_OWNER, + repo: GH_REPO, + commit_sha: commitSha, + }); + if (commitData.status !== 200) { + console.error('commitData:'); + console.error(commitData); + throw new Error( + 'Something went wrong when getting the commit message using commit SHA', + ); + } + + // Example Commit Message + // Merge pull request #3555 from backstage/changeset-release/master Version Packages + return commitData.data.message; +} + +// There is a PR number in our expected commit message. Get the description of that PR. +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)) { + throw new Error( + `Expected regex did not match commit message: ${commitMessage}`, + ); + } + + // Get the PR description from the commit message + const prNumber = commitMessage.match(expectedMessage).groups.prNumber; + console.log( + `Identified the changeset Pull request - https://github.com/backstage/backstage/pull/${prNumber}`, + ); + + const { data } = await octokit.pulls.get({ + owner: GH_OWNER, + repo: GH_REPO, + pull_number: prNumber, + }); + + // 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'); + } + + return data.body; +} + +// Create Release on GitHub. +async function createRelease(releaseDescription) { + // Create draft release if BOOL_CREATE_RELEASE is undefined + // Publish release if BOOL_CREATE_RELEASE is not undefined + const boolCreateDraft = !BOOL_CREATE_RELEASE; + + const releaseResponse = await octokit.repos.createRelease({ + owner: GH_REPO, + repo: GH_REPO, + tag_name: TAG_NAME, + name: TAG_NAME, + body: releaseDescription, + draft: boolCreateDraft, + prerelease: false, + }); + + if (releaseResponse.status === 201) { + if (boolCreateDraft) { + console.log('Created draft release! Click Publish to notify users.'); + } else { + console.log('Published release!'); + } + console.log(releaseResponse.data.html_url); + } else { + console.error(releaseResponse); + throw new Error('Something went wrong when creating the release.'); + } +} + +async function main() { + const commitMessage = await getCommitMessageUsingTagName(TAG_NAME); + const releaseDescription = await getReleaseDescriptionFromCommitMessage( + commitMessage, + ); + + await createRelease(releaseDescription); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/create-release-tag.js b/scripts/create-release-tag.js new file mode 100755 index 0000000000..bcfe9153ff --- /dev/null +++ b/scripts/create-release-tag.js @@ -0,0 +1,80 @@ +#!/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. + */ + +const { Octokit } = require('@octokit/rest'); + +const baseOptions = { + owner: 'backstage', + repo: 'backstage', +}; + +async function main() { + const { GITHUB_SHA, GITHUB_TOKEN } = process.env; + if (!GITHUB_SHA) { + throw new Error('GITHUB_SHA is not set'); + } + if (!GITHUB_TOKEN) { + throw new Error('GITHUB_TOKEN is not set'); + } + + const octokit = new Octokit({ auth: GITHUB_TOKEN }); + + const date = new Date(); + 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'); + + const existingTags = await octokit.repos.listTags({ + ...baseOptions, + per_page: 100, + }); + const existingTagNames = existingTags.data.map(obj => obj.name); + + let tagName = baseTagName; + let index = 0; + while (existingTagNames.includes(tagName)) { + index += 1; + tagName = `${baseTagName}.${index}`; + } + + console.log(`Creating release tag ${tagName}`); + + const annotatedTag = await octokit.git.createTag({ + ...baseOptions, + tag: tagName, + message: tagName, + object: GITHUB_SHA, + type: 'commit', + }); + + await octokit.git.createRef({ + ...baseOptions, + ref: `refs/tags/${tagName}`, + sha: annotatedTag.data.sha, + }); + + console.log(`::set-output name=tag_name::${tagName}`); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/isolated-release.js b/scripts/isolated-release.js new file mode 100755 index 0000000000..27dfd35b07 --- /dev/null +++ b/scripts/isolated-release.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); +const childProcess = require('child_process'); +// eslint-disable-next-line import/no-extraneous-dependencies +const { Project } = require('@lerna/project'); + +// Prepare a release of the provided packages, e.g. @backstage/core +async function main(args) { + if (args.includes('--help') || args.length === 0) { + const arg0 = path.relative(process.cwd(), process.argv[1]); + console.log(`Usage: ${process.argv0} ${arg0} ...`); + process.exit(1); + } + + const project = new Project(__dirname); + const packages = await project.getPackages(); + const ignoreArgs = packages + .filter(p => !args.includes(p.name)) + .flatMap(p => ['--ignore', p.name]); + + const { status } = childProcess.spawnSync( + 'yarn', + ['changeset', 'version', ...ignoreArgs], + { + stdio: 'inherit', + }, + ); + if (status !== 0) { + return; + } + + childProcess.spawnSync( + 'yarn', + ['prettier', '--write', '{packages,plugins}/*/{package.json,CHANGELOG.md}'], + { + stdio: 'inherit', + }, + ); +} + +main(process.argv.slice(2)).catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/migrate-location-types.js b/scripts/migrate-location-types.js new file mode 100755 index 0000000000..eb2f5fb860 --- /dev/null +++ b/scripts/migrate-location-types.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This is a script that helps you migrate any locations that are using +// deprecated location types. Change the below URL to point towards your +// catalog API endpoint and execute the script. It will delete and add +// back the locations with the correct type one by one. + +const BASE_URL = 'http://localhost:7000/api/catalog'; // Change me + +const deprecatedTypes = [ + 'github', + 'github/api', + 'bitbucket/api', + 'gitlab/api', + 'azure/api', +]; + +async function main() { + const locations = await request('GET', `${BASE_URL}/locations`); + + for (const { data: location } of locations) { + if (!deprecatedTypes.includes(location.type)) { + continue; + } + console.log(`${location.type} -> url : ${location.target}`); + await request('DELETE', `${BASE_URL}/locations/${location.id}`); + try { + await request('POST', `${BASE_URL}/locations`, { + type: 'url', + target: location.target, + }); + } catch (error) { + console.log(`Failed to add back location ${location.target}`); + throw error; + } + } +} + +async function request(method, url, body) { + return new Promise((resolve, reject) => { + const parsedUrl = require('url').parse(url); + + const req = require('http').request( + { + method, + ...parsedUrl, + headers: body + ? { + 'Content-Type': 'application/json', + } + : {}, + }, + res => { + const chunks = []; + res.on('data', chunk => { + chunks.push(chunk); + }); + + res.on('end', () => { + if (res.statusCode >= 300) { + reject( + new Error( + `${method} to ${url} failed with status ${res.statusCode}`, + ), + ); + return; + } + try { + const body = Buffer.concat(chunks).toString('utf8').trim(); + if (body) { + resolve(JSON.parse(body)); + } else { + resolve(); + } + } catch (error) { + reject(error); + } + }); + }, + ); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/run-fossa.js b/scripts/run-fossa.js new file mode 100755 index 0000000000..ee9e3142e7 --- /dev/null +++ b/scripts/run-fossa.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This script generates an appropriate fossa config, and wraps the running +// of `fossa analyze` in a retry loop as it frequently fails with a 502 error + +const { resolve: resolvePath, join: joinPath, basename } = require('path'); +const { promises: fs } = require('fs'); +const { execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); + +const execFile = promisify(execFileCb); + +const FOSSA_YAML_HEAD = ` +version: 2 +cli: + server: https://app.fossa.com + fetcher: custom + project: backstage +analyze: + modules:`; + +const IGNORED_DIRS = ['node_modules', 'dist', 'bin', '.git']; + +// Finds all directories containing package.json files that we're interested in analyzing +async function findPackageJsonDirs(dir, depth = 0) { + if (depth > 2) { + return []; // Skipping packages that are deeper than 2 dirs in + } + 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 findPackageJsonDirs(path, depth + 1); + } else if (file === 'package.json') { + return dir; + } + return []; + }), + ); + return paths.flat(); +} + +// A replacement for `fossa init`, as that generates a bad config for this repo +async function generateConfig(paths) { + let content = FOSSA_YAML_HEAD; + + for (const path of paths) { + content += ` + - name: ${basename(path)} + type: npm + path: ${path} + target: ${path} + options: + strategy: yarn-list +`; + } + + return content; +} + +// Runs `fossa analyze`, with 502 errors being retried up to 3 times +async function runAnalyze(githubRef) { + for (let attempt = 1; attempt <= 3; attempt++) { + console.error(`Running fossa analyze, attempt ${attempt}`); + try { + const { stdout, stderr } = await execFile( + 'fossa', + ['analyze', '--branch', githubRef], + { shell: true }, + ); + console.error(stderr); + console.log(stdout); + + return; // Analyze was successful, we're done + } catch (error) { + if (!error.code) { + throw error; + } + if (error.stderr) { + process.stderr.write(error.stderr); + } + if (error.stdout) { + process.stdout.write(error.stdout); + } + if (error.stderr && error.stderr.includes('502 Bad Gateway')) { + console.error('Encountered 502 during fossa analysis upload, retrying'); + continue; + } + throw new Error(`Fossa analyze failed with code ${error.code}`); + } + } + + console.error('Maximum number of retries reached, skipping fossa analysis'); +} + +async function main() { + const githubRef = process.env.GITHUB_REF; + if (!githubRef) { + throw new Error('GITHUB_REF is not set'); + } + // This is picked up by the fossa CLI and should be set + if (!process.env.FOSSA_API_KEY) { + throw new Error('FOSSA_API_KEY is not set'); + } + + process.cwd(resolvePath(__dirname, '..')); + + const packageJsonPaths = await findPackageJsonDirs('.'); + + const configContents = await generateConfig(packageJsonPaths); + + await fs.writeFile('.fossa.yml', configContents, 'utf8'); + + console.error(`Generated fossa config:\n${configContents}`); + + await runAnalyze(githubRef); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); 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 9bd7e9f067..d5c45157e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,6 +44,19 @@ dependencies: xss "^1.0.6" +"@apollographql/graphql-upload-8-fork@^8.1.3": + version "8.1.3" + resolved "https://registry.npmjs.org/@apollographql/graphql-upload-8-fork/-/graphql-upload-8-fork-8.1.3.tgz#a0d4e0d5cec8e126d78bd915c264d6b90f5784bc" + integrity sha512-ssOPUT7euLqDXcdVv3Qs4LoL4BPtfermW1IOouaqEmj36TpHYDmYDIbKoSQxikd9vtMumFnP87OybH7sC9fJ6g== + dependencies: + "@types/express" "*" + "@types/fs-capacitor" "*" + "@types/koa" "*" + busboy "^0.3.1" + fs-capacitor "^2.0.4" + http-errors "^1.7.3" + object-path "^0.11.4" + "@ardatan/aggregate-error@0.0.1": version "0.0.1" resolved "https://registry.npmjs.org/@ardatan/aggregate-error/-/aggregate-error-0.0.1.tgz#1403ac5de10d8ca689fc1f65844c27179ae1d44f" @@ -68,13 +81,13 @@ 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.4.0": + version "1.4.0" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.4.0.tgz#a0b0e3cb1803400d93b15f7c354fe95824a36e2c" + integrity sha512-rI0UF9niaqUxZqFS2+iBvkekxhI3cXOv/O+i+w/1EhpPppLicPVnazQbBqQ1iO37sio+CPejJbeoBgj8m+ylPw== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" - "@asyncapi/specs" "^2.7.5" + "@asyncapi/specs" "^2.7.6" "@fmvilas/pseudo-yaml-ast" "^0.3.1" ajv "^6.10.1" js-yaml "^3.13.1" @@ -82,29 +95,225 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" -"@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== +"@asyncapi/react-component@^0.19.2": + version "0.19.2" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.19.2.tgz#364a1d839e6f49a454fcb44b544f9496d1501b93" + integrity sha512-NXLBVaJdXqsIX98tF8fKxjTDf6cE+ym3Fkp3IQhMQh/6o1X2y/YVp+XkjfLO2C/Ccb/Cys1C5OnqipfwEY1oqA== + dependencies: + "@asyncapi/avro-schema-parser" "^0.2.0" + "@asyncapi/openapi-schema-parser" "^2.0.0" + "@asyncapi/parser" "^1.4.0" + 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" -"@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== +"@asyncapi/specs@^2.7.6": + version "2.7.6" + resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.6.tgz#d04ad015a148cd222f7939b9561360ab903de632" + integrity sha512-2IYlLA02beYQKVJVCAqV+G5tXRI4s8yeKYiszuoxS178psdAMKxgXtng8hUYPOqSvU6X4lc+Jabk1zSQFHifVg== + +"@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.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.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.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.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/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/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/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== + +"@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" @@ -126,28 +335,24 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" - integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== - dependencies: - browserslist "^4.12.0" - invariant "^2.2.4" - semver "^5.5.0" +"@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== -"@babel/core@^7.0.0", "@babel/core@^7.9.0": - version "7.11.1" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643" - integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ== +"@babel/core@^7.0.0", "@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.4.4", "@babel/core@^7.7.5": + version "7.12.9" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" + integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.0" - "@babel/helper-module-transforms" "^7.11.0" - "@babel/helpers" "^7.10.4" - "@babel/parser" "^7.11.1" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.11.0" - "@babel/types" "^7.11.0" + "@babel/generator" "^7.12.5" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helpers" "^7.12.5" + "@babel/parser" "^7.12.7" + "@babel/template" "^7.12.7" + "@babel/traverse" "^7.12.9" + "@babel/types" "^7.12.7" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" @@ -157,28 +362,6 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.1.0", "@babel/core@^7.4.4", "@babel/core@^7.7.5": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.9.6.tgz#d9aa1f580abf3b2286ef40b6904d390904c63376" - integrity sha512-nD3deLvbsApbHAHttzIssYqgb883yU/d9roe4RZymBCDaZryMJDbptVpEpeQuRh4BJ+SYI8le9YGxKvFEvl1Wg== - dependencies: - "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.9.6" - "@babel/helper-module-transforms" "^7.9.0" - "@babel/helpers" "^7.9.6" - "@babel/parser" "^7.9.6" - "@babel/template" "^7.8.6" - "@babel/traverse" "^7.9.6" - "@babel/types" "^7.9.6" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.1" - json5 "^2.1.2" - lodash "^4.17.13" - resolve "^1.3.2" - semver "^5.4.1" - source-map "^0.5.0" - "@babel/generator@^7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.10.5.tgz#1b903554bc8c583ee8d25f1e8969732e6b829a69" @@ -206,6 +389,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" + integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== + dependencies: + "@babel/types" "^7.12.5" + 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" @@ -213,6 +405,13 @@ dependencies: "@babel/types" "^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.12.10" + "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3" @@ -230,6 +429,24 @@ "@babel/helper-module-imports" "^7.10.4" "@babel/types" "^7.10.5" +"@babel/helper-builder-react-jsx-experimental@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.10.tgz#a58cb96a793dc0fcd5c9ed3bb36d62fdc60534c2" + integrity sha512-3Kcr2LGpL7CTRDTTYm1bzeor9qZbxbvU2AxsLA6mUG9gYarSfIKMK0UlU+azLWI+s0+BH768bwyaziWB2NOJlQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" + "@babel/types" "^7.12.10" + +"@babel/helper-builder-react-jsx-experimental@^7.12.4": + version "7.12.4" + resolved "https://registry.npmjs.org/@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" @@ -238,18 +455,17 @@ "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-compilation-targets@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" - integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ== +"@babel/helper-compilation-targets@^7.12.5": + version "7.12.5" + 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.10.4" - browserslist "^4.12.0" - invariant "^2.2.4" - levenary "^1.1.1" + "@babel/compat-data" "^7.12.5" + "@babel/helper-validator-option" "^7.12.1" + browserslist "^4.14.5" semver "^5.5.0" -"@babel/helper-create-class-features-plugin@^7.10.4", "@babel/helper-create-class-features-plugin@^7.10.5": +"@babel/helper-create-class-features-plugin@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== @@ -261,6 +477,17 @@ "@babel/helper-replace-supers" "^7.10.4" "@babel/helper-split-export-declaration" "^7.10.4" +"@babel/helper-create-class-features-plugin@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-member-expression-to-functions" "^7.12.1" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@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" @@ -270,6 +497,14 @@ "@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.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" + regexpu-core "^4.7.1" + "@babel/helper-define-map@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" @@ -317,6 +552,13 @@ dependencies: "@babel/types" "^7.10.5" +"@babel/helper-member-expression-to-functions@^7.12.1": + version "7.12.7" + 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.0.0", "@babel/helper-module-imports@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" @@ -324,7 +566,14 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0", "@babel/helper-module-transforms@^7.9.0": +"@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": + version "7.12.5" + 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.10.4": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== @@ -337,6 +586,21 @@ "@babel/types" "^7.11.0" lodash "^4.17.19" +"@babel/helper-module-transforms@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-simple-access" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/helper-validator-identifier" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.12.1" + "@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" @@ -356,16 +620,14 @@ dependencies: lodash "^4.17.19" -"@babel/helper-remap-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5" - integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg== +"@babel/helper-remap-async-to-generator@^7.12.1": + version "7.12.1" + 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" "@babel/helper-wrap-function" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.10.4": version "7.10.4" @@ -377,6 +639,16 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/helper-replace-supers@^7.12.1": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.5.tgz#f009a17543bbbbce16b06206ae73b63d3fca68d9" + integrity sha512-5YILoed0ZyIpF4gKcpZitEnXEJ9UoDRki1Ey6xz46rxOzfNMAhVIJMoune1hmPVxh40LRv1+oafz7UsWX+vyWA== + 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-simple-access@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" @@ -385,6 +657,13 @@ "@babel/template" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/helper-simple-access@^7.12.1": + version "7.12.1" + 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.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" @@ -392,6 +671,13 @@ dependencies: "@babel/types" "^7.11.0" +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + 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", "@babel/helper-split-export-declaration@^7.8.3": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" @@ -404,6 +690,11 @@ 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-option@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" + integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== + "@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" @@ -414,14 +705,14 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helpers@^7.10.4", "@babel/helpers@^7.9.6": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" - integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== +"@babel/helpers@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" "@babel/highlight@^7.0.0", "@babel/highlight@^7.10.4", "@babel/highlight@^7.8.3": version "7.10.4" @@ -437,7 +728,7 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== -"@babel/parser@^7.0.0", "@babel/parser@^7.11.0", "@babel/parser@^7.11.1": +"@babel/parser@^7.0.0", "@babel/parser@^7.11.0": version "7.11.3" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== @@ -452,16 +743,21 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.10.5.tgz#e7c6bf5a7deff957cec9f04b551e2762909d826b" integrity sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ== -"@babel/plugin-proposal-async-generator-functions@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" - integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg== +"@babel/parser@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" + integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg== + +"@babel/plugin-proposal-async-generator-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" + integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" -"@babel/plugin-proposal-class-properties@^7.0.0", "@babel/plugin-proposal-class-properties@^7.10.4", "@babel/plugin-proposal-class-properties@^7.8.3": +"@babel/plugin-proposal-class-properties@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== @@ -469,72 +765,80 @@ "@babel/helper-create-class-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-decorators@^7.8.3": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.10.5.tgz#42898bba478bc4b1ae242a703a953a7ad350ffb4" - integrity sha512-Sc5TAQSZuLzgY0664mMDn24Vw2P8g/VhyLyGPaWiHahhgLqeZvcGeyBZOrJW0oSKIK2mvQ22a1ENXBIQLhrEiQ== +"@babel/plugin-proposal-class-properties@^7.12.1": + version "7.12.1" + 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.10.5" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-decorators" "^7.10.4" -"@babel/plugin-proposal-dynamic-import@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e" - integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ== +"@babel/plugin-proposal-decorators@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.12.1.tgz#59271439fed4145456c41067450543aee332d15f" + integrity sha512-knNIuusychgYN8fGJHONL0RbFxLGawhXOJNLBk75TniTsZZeA+wdkDuv6wp4lGwzQEKjZi6/WYtnb3udNPmQmQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-decorators" "^7.12.1" + +"@babel/plugin-proposal-dynamic-import@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-dynamic-import" "^7.8.0" -"@babel/plugin-proposal-export-default-from@^7.8.3": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.10.4.tgz#08f66eef0067cbf6a7bc036977dcdccecaf0c6c5" - integrity sha512-G1l00VvDZ7Yk2yRlC5D8Ybvu3gmeHS3rCHoUYdjrqGYUtdeOBoRypnvDZ5KQqxyaiiGHWnVDeSEzA5F9ozItig== +"@babel/plugin-proposal-export-default-from@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.12.1.tgz#c6e62d668a8abcfe0d28b82f560395fecb611c5a" + integrity sha512-z5Q4Ke7j0AexQRfgUvnD+BdCSgpTEKnqQ3kskk2jWtOBulxICzd1X9BGt7kmWftxZ2W3++OZdt5gtmC8KLxdRQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-export-default-from" "^7.10.4" + "@babel/plugin-syntax-export-default-from" "^7.12.1" -"@babel/plugin-proposal-export-namespace-from@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" - integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg== +"@babel/plugin-proposal-export-namespace-from@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db" - integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw== +"@babel/plugin-proposal-json-strings@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-json-strings" "^7.8.0" -"@babel/plugin-proposal-logical-assignment-operators@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8" - integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q== +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" - integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== +"@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" -"@babel/plugin-proposal-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06" - integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA== +"@babel/plugin-proposal-numeric-separator@^7.12.7": + version "7.12.7" + 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" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.6": +"@babel/plugin-proposal-object-rest-spread@^7.0.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== @@ -543,32 +847,49 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-transform-parameters" "^7.10.4" -"@babel/plugin-proposal-optional-catch-binding@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" - integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g== +"@babel/plugin-proposal-object-rest-spread@^7.12.1": + version "7.12.1" + 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" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-transform-parameters" "^7.12.1" + +"@babel/plugin-proposal-optional-catch-binding@^7.12.1": + version "7.12.1" + 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" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.10.1", "@babel/plugin-proposal-optional-chaining@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" - integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== +"@babel/plugin-proposal-optional-chaining@^7.12.1", "@babel/plugin-proposal-optional-chaining@^7.12.7": + version "7.12.7" + 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" - "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-private-methods@^7.10.4", "@babel/plugin-proposal-private-methods@^7.8.3": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" - integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== +"@babel/plugin-proposal-private-methods@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": +"@babel/plugin-proposal-unicode-property-regex@^7.12.1": + version "7.12.1" + 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== @@ -590,17 +911,24 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3": +"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-decorators@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.10.4.tgz#6853085b2c429f9d322d02f5a635018cdeb2360c" - integrity sha512-2NaoC6fAk2VMdhY1eerkfHV+lVYC1u8b+jmRJISqANCJlTxYy19HGdIkkQtix2UtkcPuPu+IlDgrVseZnU03bw== +"@babel/plugin-syntax-class-properties@^7.12.1": + version "7.12.1" + 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" + +"@babel/plugin-syntax-decorators@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.12.1.tgz#81a8b535b284476c41be6de06853a8802b98c5dd" + integrity sha512-ir9YW5daRrTYiy9UJ2TzdNIJEZu8KclVzDcfSt4iEmOtwQ4llPtWInNKJyKnVXp1vE4bbVd5S31M/im3mYMO1w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -611,10 +939,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-export-default-from@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.10.4.tgz#e5494f95006355c10292a0ff1ce42a5746002ec8" - integrity sha512-79V6r6Pgudz0RnuMGp5xidu6Z+bPFugh8/Q9eDHonmLp4wKFAZDwygJwYgCzuDu8lFA/sYyT+mc5y2wkd7bTXA== +"@babel/plugin-syntax-export-default-from@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.12.1.tgz#a9eb31881f4f9a1115a3d2c6d64ac3f6016b5a9d" + integrity sha512-dP5eGg6tHEkhnRD2/vRG/KJKRSg8gtxu2i+P/8/yFPJn/CfPU5G0/7Gks2i3M6IOVAPQekmsLN9LPsmXFFL4Uw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -632,12 +960,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-flow@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.8.3.tgz#f2c883bd61a6316f2c89380ae5122f923ba4527f" - integrity sha512-innAx3bUbA0KSYj2E2MNFSn9hiCeowOFLxlsuhXzw8hMQnzkDomUr9QCD7E9VF60NmnG1sNTuuv6Qf4f8INYsg== +"@babel/plugin-syntax-flow@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.12.1.tgz#a77670d9abe6d63e8acadf4c31bb1eb5a506bbdd" + integrity sha512-1lBLLmtxrwpm4VKmtVFselI/P3pX+G63fAtUUt6b2Nzgao77KNDwyuRt90Mj2/9pKobtt68FdvjfqohZjg/FCA== dependencies: - "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" @@ -660,6 +988,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-syntax-jsx@^7.12.1": + version "7.12.1" + 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" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -709,58 +1044,72 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d" - integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ== +"@babel/plugin-syntax-top-level-await@^7.12.1", "@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.12.1" + 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-syntax-typescript@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.4.tgz#2f55e770d3501e83af217d782cb7517d7bb34d25" - integrity sha512-oSAEz1YkBCAKr5Yiq8/BNtvSAPwkp/IyUnwZogd8p+F0RuYQQrLeRUzIQhueQTTBy/F+a40uS7OFKxnkRvmvFQ== +"@babel/plugin-syntax-typescript@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.1.tgz#460ba9d77077653803c3dd2e673f76d66b4029e5" + integrity sha512-UZNEcCY+4Dp9yYRCAHrHDU+9ZXLYaY9MgBXSRLkB9WjYFRR6quJBumfVrEkUxrePPBwFcpWfNKXqVRQQtm7mMA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.10.4", "@babel/plugin-transform-arrow-functions@^7.8.3": +"@babel/plugin-transform-arrow-functions@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37" - integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ== +"@babel/plugin-transform-arrow-functions@^7.12.1": + version "7.12.1" + 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-module-imports" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" -"@babel/plugin-transform-block-scoped-functions@^7.0.0", "@babel/plugin-transform-block-scoped-functions@^7.10.4": +"@babel/plugin-transform-async-to-generator@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" + +"@babel/plugin-transform-block-scoped-functions@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8" integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.0.0", "@babel/plugin-transform-block-scoping@^7.8.3": +"@babel/plugin-transform-block-scoped-functions@^7.12.1": + version "7.12.1" + 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.0.0": version "7.11.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.5.tgz#b81b8aafefbfe68f0f65f7ef397b9ece68a6037d" - integrity sha512-6Ycw3hjpQti0qssQcA6AMSFDHeNJ++R6dIMnpRqUjFeBBTmTDPa8zgF90OVfTvAo11mXZTlVUViY1g8ffrURLg== +"@babel/plugin-transform-block-scoping@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" + integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.10.4", "@babel/plugin-transform-classes@^7.9.5": +"@babel/plugin-transform-classes@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== @@ -774,21 +1123,57 @@ "@babel/helper-split-export-declaration" "^7.10.4" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.0.0", "@babel/plugin-transform-computed-properties@^7.10.4": +"@babel/plugin-transform-classes@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-define-map" "^7.10.4" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-split-export-declaration" "^7.10.4" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb" integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.10.4", "@babel/plugin-transform-destructuring@^7.9.5": +"@babel/plugin-transform-computed-properties@^7.12.1": + version "7.12.1" + 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.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4": +"@babel/plugin-transform-destructuring@^7.12.1": + version "7.12.1" + 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": + version "7.12.1" + 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== @@ -796,17 +1181,17 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-duplicate-keys@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" - integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA== +"@babel/plugin-transform-duplicate-keys@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e" - integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw== +"@babel/plugin-transform-exponentiation-operator@^7.12.1": + version "7.12.1" + 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" "@babel/helper-plugin-utils" "^7.10.4" @@ -819,22 +1204,29 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-flow" "^7.10.4" -"@babel/plugin-transform-flow-strip-types@^7.9.0": - version "7.9.0" - resolved "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.9.0.tgz#8a3538aa40434e000b8f44a3c5c9ac7229bd2392" - integrity sha512-7Qfg0lKQhEHs93FChxVLAvhBshOPQDtJUTVHr/ZwQNRccCm4O9D79r9tVSoV8iNwjP1YgfD+e/fgHcPkN1qEQg== +"@babel/plugin-transform-flow-strip-types@^7.12.1": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.12.10.tgz#d85e30ecfa68093825773b7b857e5085bbd32c95" + integrity sha512-0ti12wLTLeUIzu9U7kjqIn4MyOL7+Wibc7avsHhj4o1l5C0ATs8p2IMHrVYjm9t9wzhfEO6S3kxax0Rpdo8LTg== dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-syntax-flow" "^7.8.3" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-flow" "^7.12.1" -"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.10.4", "@babel/plugin-transform-for-of@^7.9.0": +"@babel/plugin-transform-for-of@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-function-name@^7.0.0", "@babel/plugin-transform-function-name@^7.10.4": +"@babel/plugin-transform-for-of@^7.12.1": + version "7.12.1" + 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.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7" integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg== @@ -842,30 +1234,52 @@ "@babel/helper-function-name" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-literals@^7.0.0", "@babel/plugin-transform-literals@^7.10.4": +"@babel/plugin-transform-function-name@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-literals@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c" integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-member-expression-literals@^7.0.0", "@babel/plugin-transform-member-expression-literals@^7.10.4": +"@babel/plugin-transform-literals@^7.12.1": + version "7.12.1" + 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.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7" integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-modules-amd@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1" - integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw== +"@babel/plugin-transform-member-expression-literals@^7.12.1": + version "7.12.1" + 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-module-transforms" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-modules-amd@^7.12.1": + version "7.12.1" + 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" "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.10.4", "@babel/plugin-transform-modules-commonjs@^7.4.4": +"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== @@ -875,39 +1289,50 @@ "@babel/helper-simple-access" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" - integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw== +"@babel/plugin-transform-modules-commonjs@^7.12.1": + version "7.12.1" + 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-hoist-variables" "^7.10.4" - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-simple-access" "^7.12.1" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e" - integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA== +"@babel/plugin-transform-modules-systemjs@^7.12.1": + version "7.12.1" + 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-module-transforms" "^7.10.4" + "@babel/helper-hoist-variables" "^7.10.4" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-identifier" "^7.10.4" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-umd@^7.12.1": + version "7.12.1" + 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" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6" - integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA== +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": + version "7.12.1" + 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.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" -"@babel/plugin-transform-new-target@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888" - integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw== +"@babel/plugin-transform-new-target@^7.12.1": + version "7.12.1" + 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.0.0", "@babel/plugin-transform-object-super@^7.10.4": +"@babel/plugin-transform-object-super@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894" integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ== @@ -915,7 +1340,15 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-replace-supers" "^7.10.4" -"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.10.4", "@babel/plugin-transform-parameters@^7.9.5": +"@babel/plugin-transform-object-super@^7.12.1": + version "7.12.1" + 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" + "@babel/helper-replace-supers" "^7.12.1" + +"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw== @@ -923,53 +1356,58 @@ "@babel/helper-get-function-arity" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-property-literals@^7.0.0", "@babel/plugin-transform-property-literals@^7.10.4": +"@babel/plugin-transform-parameters@^7.12.1": + version "7.12.1" + 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.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0" integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-constant-elements@^7.7.4", "@babel/plugin-transform-react-constant-elements@^7.9.0": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.10.4.tgz#0f485260bf1c29012bb973e7e404749eaac12c9e" - integrity sha512-cYmQBW1pXrqBte1raMkAulXmi7rjg3VI6ZLg9QIic8Hq7BtYXaWuZSxsr2siOMI6SWwpxjWfnwhTUrd7JlAV7g== +"@babel/plugin-transform-property-literals@^7.12.1": + version "7.12.1" + 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.0.0", "@babel/plugin-transform-react-display-name@^7.10.4": +"@babel/plugin-transform-react-constant-elements@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.12.1.tgz#4471f0851feec3231cc9aaa0dccde39947c1ac1e" + integrity sha512-KOHd0tIRLoER+J+8f9DblZDa1fLGPwaaN1DI1TVHuQFOpjHV22C3CUB3obeC4fexHY9nx+fH0hQNvLFFfA1mxA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-react-display-name@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.10.4.tgz#b5795f4e3e3140419c3611b7a2a3832b9aef328d" integrity sha512-Zd4X54Mu9SBfPGnEcaGcOrVAYOtjT2on8QZkLKEq1S/tHexG39d9XXGZv19VfRrDjPJzFmPfTAqOQS1pfFOujw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-jsx-development@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.10.4.tgz#6ec90f244394604623880e15ebc3c34c356258ba" - integrity sha512-RM3ZAd1sU1iQ7rI2dhrZRZGv0aqzNQMbkIUCS1txYpi9wHQ2ZHNjo5TwX+UD6pvFW4AbWqLVYvKy5qJSAyRGjQ== - dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" - -"@babel/plugin-transform-react-jsx-self@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369" - integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg== +"@babel/plugin-transform-react-display-name@^7.12.1": + version "7.12.1" + 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-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-jsx-source@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.10.5.tgz#34f1779117520a779c054f2cdd9680435b9222b4" - integrity sha512-wTeqHVkN1lfPLubRiZH3o73f4rfon42HpgxUSs86Nc+8QIcm/B9s8NNVXu/gwGcOyd7yDib9ikxoDLxJP0UiDA== +"@babel/plugin-transform-react-jsx-development@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.7.tgz#4c2a647de79c7e2b16bfe4540677ba3121e82a08" + integrity sha512-Rs3ETtMtR3VLXFeYRChle5SsP/P9Jp/6dsewBQfokDSzKJThlsuFcnzLTDRALiUmTC48ej19YD9uN1mupEeEDg== dependencies: + "@babel/helper-builder-react-jsx-experimental" "^7.12.4" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.12.1" -"@babel/plugin-transform-react-jsx@^7.0.0", "@babel/plugin-transform-react-jsx@^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" integrity sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A== @@ -979,36 +1417,53 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-pure-annotations@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.10.4.tgz#3eefbb73db94afbc075f097523e445354a1c6501" - integrity sha512-+njZkqcOuS8RaPakrnR9KvxjoG1ASJWpoIv/doyWngId88JoFlPlISenGXjrVacZUIALGUr6eodRs1vmPnF23A== +"@babel/plugin-transform-react-jsx@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.10.tgz#a7af3097c73479123594c8c8fe39545abebd44e3" + integrity sha512-MM7/BC8QdHXM7Qc1wdnuk73R4gbuOpfrSUgfV/nODGc86sPY1tgmY2M9E9uAnf2e4DOIp8aKGWqgZfQxnTNGuw== + dependencies: + "@babel/helper-builder-react-jsx" "^7.10.4" + "@babel/helper-builder-react-jsx-experimental" "^7.12.10" + "@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" + integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-regenerator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63" - integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw== +"@babel/plugin-transform-regenerator@^7.12.1": + version "7.12.1" + 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.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd" - integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ== +"@babel/plugin-transform-reserved-words@^7.12.1": + version "7.12.1" + 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.0.0", "@babel/plugin-transform-shorthand-properties@^7.10.4", "@babel/plugin-transform-shorthand-properties@^7.8.3": +"@babel/plugin-transform-shorthand-properties@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.11.0", "@babel/plugin-transform-spread@^7.8.3": +"@babel/plugin-transform-shorthand-properties@^7.12.1": + version "7.12.1" + 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.0.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== @@ -1016,15 +1471,22 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" -"@babel/plugin-transform-sticky-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" - integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ== +"@babel/plugin-transform-spread@^7.12.1": + version "7.12.1" + 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" - "@babel/helper-regex" "^7.10.4" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" -"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3": +"@babel/plugin-transform-sticky-regex@^7.12.7": + version "7.12.7" + 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.0.0": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== @@ -1032,61 +1494,69 @@ "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-typeof-symbol@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc" - integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA== +"@babel/plugin-transform-template-literals@^7.12.1": + version "7.12.1" + 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-typescript@^7.10.4": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.11.0.tgz#2b4879676af37342ebb278216dd090ac67f13abb" - integrity sha512-edJsNzTtvb3MaXQwj8403B7mZoGu9ElDJQZOKjGUnvilquxBA3IQoEIOvkX/1O8xfAsnHS/oQhe2w/IXrr+w0w== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.10.5" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-typescript" "^7.10.4" - -"@babel/plugin-transform-unicode-escapes@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" - integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg== +"@babel/plugin-transform-typeof-symbol@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" + integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-unicode-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8" - integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A== +"@babel/plugin-transform-typescript@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.12.1.tgz#d92cc0af504d510e26a754a7dbc2e5c8cd9c7ab4" + integrity sha512-VrsBByqAIntM+EYMqSm59SiMEf7qkmI9dqMt6RbD/wlwueWmYcI0FFK5Fj47pP6DRZm+3teXjosKlwcZJ5lIMw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-typescript" "^7.12.1" + +"@babel/plugin-transform-unicode-escapes@^7.12.1": + version "7.12.1" + 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-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/preset-env@^7.9.5", "@babel/preset-env@^7.9.6": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" - integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== +"@babel/plugin-transform-unicode-regex@^7.12.1": + version "7.12.1" + 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/compat-data" "^7.11.0" - "@babel/helper-compilation-targets" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-proposal-async-generator-functions" "^7.10.4" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-dynamic-import" "^7.10.4" - "@babel/plugin-proposal-export-namespace-from" "^7.10.4" - "@babel/plugin-proposal-json-strings" "^7.10.4" - "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" - "@babel/plugin-proposal-numeric-separator" "^7.10.4" - "@babel/plugin-proposal-object-rest-spread" "^7.11.0" - "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" - "@babel/plugin-proposal-optional-chaining" "^7.11.0" - "@babel/plugin-proposal-private-methods" "^7.10.4" - "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" + +"@babel/preset-env@^7.12.1": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.7.tgz#54ea21dbe92caf6f10cb1a0a576adc4ebf094b55" + integrity sha512-OnNdfAr1FUQg7ksb7bmbKoby4qFOHw6DKWWUNB9KqnnCldxhxJlP+21dpyaWFmf2h0rTbOkXJtAGevY3XW1eew== + 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/plugin-proposal-async-generator-functions" "^7.12.1" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-dynamic-import" "^7.12.1" + "@babel/plugin-proposal-export-namespace-from" "^7.12.1" + "@babel/plugin-proposal-json-strings" "^7.12.1" + "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-numeric-separator" "^7.12.7" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.7" + "@babel/plugin-proposal-private-methods" "^7.12.1" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" - "@babel/plugin-syntax-class-properties" "^7.10.4" + "@babel/plugin-syntax-class-properties" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.0" @@ -1096,54 +1566,51 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" - "@babel/plugin-syntax-top-level-await" "^7.10.4" - "@babel/plugin-transform-arrow-functions" "^7.10.4" - "@babel/plugin-transform-async-to-generator" "^7.10.4" - "@babel/plugin-transform-block-scoped-functions" "^7.10.4" - "@babel/plugin-transform-block-scoping" "^7.10.4" - "@babel/plugin-transform-classes" "^7.10.4" - "@babel/plugin-transform-computed-properties" "^7.10.4" - "@babel/plugin-transform-destructuring" "^7.10.4" - "@babel/plugin-transform-dotall-regex" "^7.10.4" - "@babel/plugin-transform-duplicate-keys" "^7.10.4" - "@babel/plugin-transform-exponentiation-operator" "^7.10.4" - "@babel/plugin-transform-for-of" "^7.10.4" - "@babel/plugin-transform-function-name" "^7.10.4" - "@babel/plugin-transform-literals" "^7.10.4" - "@babel/plugin-transform-member-expression-literals" "^7.10.4" - "@babel/plugin-transform-modules-amd" "^7.10.4" - "@babel/plugin-transform-modules-commonjs" "^7.10.4" - "@babel/plugin-transform-modules-systemjs" "^7.10.4" - "@babel/plugin-transform-modules-umd" "^7.10.4" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" - "@babel/plugin-transform-new-target" "^7.10.4" - "@babel/plugin-transform-object-super" "^7.10.4" - "@babel/plugin-transform-parameters" "^7.10.4" - "@babel/plugin-transform-property-literals" "^7.10.4" - "@babel/plugin-transform-regenerator" "^7.10.4" - "@babel/plugin-transform-reserved-words" "^7.10.4" - "@babel/plugin-transform-shorthand-properties" "^7.10.4" - "@babel/plugin-transform-spread" "^7.11.0" - "@babel/plugin-transform-sticky-regex" "^7.10.4" - "@babel/plugin-transform-template-literals" "^7.10.4" - "@babel/plugin-transform-typeof-symbol" "^7.10.4" - "@babel/plugin-transform-unicode-escapes" "^7.10.4" - "@babel/plugin-transform-unicode-regex" "^7.10.4" + "@babel/plugin-syntax-top-level-await" "^7.12.1" + "@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-classes" "^7.12.1" + "@babel/plugin-transform-computed-properties" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-dotall-regex" "^7.12.1" + "@babel/plugin-transform-duplicate-keys" "^7.12.1" + "@babel/plugin-transform-exponentiation-operator" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-function-name" "^7.12.1" + "@babel/plugin-transform-literals" "^7.12.1" + "@babel/plugin-transform-member-expression-literals" "^7.12.1" + "@babel/plugin-transform-modules-amd" "^7.12.1" + "@babel/plugin-transform-modules-commonjs" "^7.12.1" + "@babel/plugin-transform-modules-systemjs" "^7.12.1" + "@babel/plugin-transform-modules-umd" "^7.12.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" + "@babel/plugin-transform-new-target" "^7.12.1" + "@babel/plugin-transform-object-super" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-property-literals" "^7.12.1" + "@babel/plugin-transform-regenerator" "^7.12.1" + "@babel/plugin-transform-reserved-words" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@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-unicode-escapes" "^7.12.1" + "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.11.5" - browserslist "^4.12.0" - core-js-compat "^3.6.2" - invariant "^2.2.2" - levenary "^1.1.1" + "@babel/types" "^7.12.7" + core-js-compat "^3.7.0" semver "^5.5.0" -"@babel/preset-flow@^7.0.0": - version "7.9.0" - resolved "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.9.0.tgz#fee847c3e090b0b2d9227c1949e4da1d1379280d" - integrity sha512-88uSmlshIrlmPkNkEcx3UpSZ6b8n0UGBq0/0ZMZCF/uxAW0XIAUuDHBhIOAh0pvweafH4RxOwi/H3rWhtqOYPA== +"@babel/preset-flow@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.12.1.tgz#1a81d376c5a9549e75352a3888f8c273455ae940" + integrity sha512-UAoyMdioAhM6H99qPoKvpHMzxmNVXno8GYU/7vZmGaHk6/KqfDYL1W0NxszVbJ2EP271b7e6Ox+Vk2A9QsB3Sw== dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-transform-flow-strip-types" "^7.9.0" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-transform-flow-strip-types" "^7.12.1" "@babel/preset-modules@^0.1.3": version "0.1.3" @@ -1156,31 +1623,30 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.0.0", "@babel/preset-react@^7.8.3", "@babel/preset-react@^7.9.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" - integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== +"@babel/preset-react@^7.12.1", "@babel/preset-react@^7.12.5": + 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.10.4" - "@babel/plugin-transform-react-jsx" "^7.10.4" - "@babel/plugin-transform-react-jsx-development" "^7.10.4" - "@babel/plugin-transform-react-jsx-self" "^7.10.4" - "@babel/plugin-transform-react-jsx-source" "^7.10.4" - "@babel/plugin-transform-react-pure-annotations" "^7.10.4" + "@babel/plugin-transform-react-display-name" "^7.12.1" + "@babel/plugin-transform-react-jsx" "^7.12.10" + "@babel/plugin-transform-react-jsx-development" "^7.12.7" + "@babel/plugin-transform-react-pure-annotations" "^7.12.1" -"@babel/preset-typescript@^7.9.0": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.4.tgz#7d5d052e52a682480d6e2cc5aa31be61c8c25e36" - integrity sha512-SdYnvGPv+bLlwkF2VkJnaX/ni1sMNetcGI1+nThF1gyv6Ph8Qucc4ZZAjM5yZcE/AKRXIOTZz7eSRDWOEjPyRQ== +"@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" + integrity sha512-nOoIqIqBmHBSEgBXWR4Dv/XBehtIFcw9PqZw6rFYuKrzsZmOQm3PR5siLBnKZFEsDb03IegG8nSjU/iXXXYRmw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-typescript" "^7.10.4" + "@babel/helper-validator-option" "^7.12.1" + "@babel/plugin-transform-typescript" "^7.12.1" -"@babel/register@^7.10.5": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/register/-/register-7.11.5.tgz#79becf89e0ddd0fba8b92bc279bc0f5d2d7ce2ea" - integrity sha512-CAml0ioKX+kOAvBQDHa/+t1fgOt3qkTIz0TrRtRAT6XY0m5qYZXR85k6/sLCNPMGhYDlCFHCYuU0ybTJbvlC6w== +"@babel/register@^7.12.1": + 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" @@ -1196,15 +1662,7 @@ core-js "^2.6.5" regenerator-runtime "^0.13.4" -"@babel/runtime-corejs3@^7.10.2": - version "7.10.3" - resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" - integrity sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw== - dependencies: - core-js-pure "^3.0.0" - regenerator-runtime "^0.13.4" - -"@babel/runtime-corejs3@^7.11.2": +"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.11.2": version "7.12.5" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== @@ -1212,10 +1670,10 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.11.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + 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" @@ -1228,7 +1686,16 @@ "@babel/parser" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/template@^7.3.3", "@babel/template@^7.8.6": +"@babel/template@^7.12.7": + version "7.12.7" + 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/template@^7.3.3": version "7.8.6" resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg== @@ -1252,7 +1719,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.0.0", "@babel/traverse@^7.11.0": +"@babel/traverse@^7.0.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24" integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg== @@ -1267,7 +1734,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.1.0", "@babel/traverse@^7.9.6": +"@babel/traverse@^7.1.0": version "7.9.6" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz#5540d7577697bf619cc57b92aa0f1c231a94f442" integrity sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg== @@ -1297,7 +1764,22 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.5", "@babel/types@^7.9.6": +"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": + version "7.12.9" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" + integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== + 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" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + +"@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.6": version "7.11.5" resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== @@ -1315,28 +1797,133 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@backstage/catalog-model@^0.2.0": - version "0.4.0" +"@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" + integrity sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ== dependencies: - "@backstage/config" "^0.1.1" + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@babel/types@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260" + integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@backstage/catalog-model@^0.2.0": + version "0.7.2" + dependencies: + "@backstage/config" "^0.1.3" "@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.4.0" + version "0.7.2" dependencies: - "@backstage/config" "^0.1.1" + "@backstage/config" "^0.1.3" "@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.6.3" + dependencies: + "@backstage/config" "^0.1.3" + "@backstage/core-api" "^0.2.11" + "@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" + "@types/react-sparklines" "^1.7.0" + "@types/react-text-truncate" "^0.14.0" + classnames "^2.2.6" + clsx "^1.1.0" + d3-selection "^2.0.0" + d3-shape "^2.0.0" + d3-zoom "^2.0.0" + dagre "^0.8.5" + immer "^8.0.1" + lodash "^4.17.15" + material-table "^1.69.1" + prop-types "^15.7.2" + qs "^6.9.4" + rc-progress "^3.0.0" + react "^16.12.0" + react-dom "^16.12.0" + react-helmet "6.1.0" + react-hook-form "^6.6.0" + react-markdown "^5.0.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-sparklines "^1.7.0" + react-syntax-highlighter "^13.5.1" + react-text-truncate "^0.16.0" + react-use "^15.3.3" + remark-gfm "^1.0.0" + zen-observable "^0.8.15" + +"@backstage/plugin-catalog@^0.2.1": + version "0.4.0" + dependencies: + "@backstage/catalog-client" "^0.3.6" + "@backstage/catalog-model" "^0.7.2" + "@backstage/core" "^0.6.3" + "@backstage/plugin-catalog-react" "^0.1.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" + "@types/react" "^16.9" + classnames "^2.2.6" + git-url-parse "^11.4.4" + 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.3.1": + version "0.4.0" + dependencies: + "@backstage/catalog-client" "^0.3.6" + "@backstage/catalog-model" "^0.7.2" + "@backstage/core" "^0.6.3" + "@backstage/plugin-catalog-react" "^0.1.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" + "@types/react" "^16.9" + classnames "^2.2.6" + git-url-parse "^11.4.4" + 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" @@ -1347,53 +1934,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.1" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.14.1.tgz#ef6ba9f69e4dd5f6cde4220e032200e3039a52f8" + integrity sha512-ydU2ZUP/s7nHQmz8TrliT+kE2dJUpZYKh8MBHrkV6suchCBhT4DmoP4VDF9M4215r18iPbMpE1TjskzXGXB8eQ== 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" @@ -1404,36 +1992,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": - version "1.3.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.3.0.tgz#82fcbf572b00ba16636be9ea45167983f1fc203b" - integrity sha512-IeAHmN5kI7OywBUNJXsk/v4vcXDDscwgTe/K5D3FSng5QTvzbgiMAe5K1iwBxBvuT4u/33n89kxSJdg4TTTFfA== +"@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.1.0" - "@manypkg/get-packages" "^1.0.1" - fs-extra "^7.0.1" - -"@changesets/config@^1.4.0": - version "1.4.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" - integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== - dependencies: - "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/logger" "^0.0.5" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" fs-extra "^7.0.1" micromatch "^4.0.2" @@ -1445,12 +2021,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" @@ -1474,10 +2050,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" @@ -1494,9 +2070,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" @@ -1526,15 +2102,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" @@ -1621,10 +2192,10 @@ "@emotion/utils" "0.11.3" "@emotion/weak-memoize" "0.2.5" -"@emotion/core@^10.0.0", "@emotion/core@^10.0.20", "@emotion/core@^10.0.28": - version "10.0.28" - resolved "https://registry.npmjs.org/@emotion/core/-/core-10.0.28.tgz#bb65af7262a234593a9e952c041d0f1c9b9bef3d" - integrity sha512-pH8UueKYO5jgg0Iq+AmCLxBsvuGtvlmiDCOuv8fGNYn3cowFpLN98L8zO56U0H1PjDIyAlXymgL3Wu7u7v6hbA== +"@emotion/core@^10.1.1": + version "10.1.1" + resolved "https://registry.npmjs.org/@emotion/core/-/core-10.1.1.tgz#c956c1365f2f2481960064bcb8c4732e5fb612c3" + integrity sha512-ZMLG6qpXR8x031NXD8HJqugy/AZSkAuMxxqB46pmAR7ze47MhNJ56cdoX243QPZdGctrdfo+s08yZTiwaUcRKA== dependencies: "@babel/runtime" "^7.5.5" "@emotion/cache" "^10.0.27" @@ -1647,14 +2218,14 @@ resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.1", "@emotion/is-prop-valid@^0.8.6": +"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.6": version "0.8.8" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== dependencies: "@emotion/memoize" "0.7.4" -"@emotion/memoize@0.7.4", "@emotion/memoize@^0.7.1": +"@emotion/memoize@0.7.4": version "0.7.4" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== @@ -1685,7 +2256,7 @@ "@emotion/serialize" "^0.11.15" "@emotion/utils" "0.11.3" -"@emotion/styled@^10.0.0", "@emotion/styled@^10.0.17": +"@emotion/styled@^10.0.23": version "10.0.27" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-10.0.27.tgz#12cb67e91f7ad7431e1875b1d83a94b814133eaf" integrity sha512-iK/8Sh7+NLJzyp9a5+vIQIXTYxfT4yB/OJbjzQanB2RZpvmzBQOHZWhpAMZWYEKRNNbsD6WfBw5sVWkb6WzS/Q== @@ -1729,80 +2300,6 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@evocateur/libnpmaccess@^3.1.2": - version "3.1.2" - resolved "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz#ecf7f6ce6b004e9f942b098d92200be4a4b1c845" - integrity sha512-KSCAHwNWro0CF2ukxufCitT9K5LjL/KuMmNzSu8wuwN2rjyKHD8+cmOsiybK+W5hdnwc5M1SmRlVCaMHQo+3rg== - dependencies: - "@evocateur/npm-registry-fetch" "^4.0.0" - aproba "^2.0.0" - figgy-pudding "^3.5.1" - get-stream "^4.0.0" - npm-package-arg "^6.1.0" - -"@evocateur/libnpmpublish@^1.2.2": - version "1.2.2" - resolved "https://registry.npmjs.org/@evocateur/libnpmpublish/-/libnpmpublish-1.2.2.tgz#55df09d2dca136afba9c88c759ca272198db9f1a" - integrity sha512-MJrrk9ct1FeY9zRlyeoyMieBjGDG9ihyyD9/Ft6MMrTxql9NyoEx2hw9casTIP4CdqEVu+3nQ2nXxoJ8RCXyFg== - dependencies: - "@evocateur/npm-registry-fetch" "^4.0.0" - aproba "^2.0.0" - figgy-pudding "^3.5.1" - get-stream "^4.0.0" - lodash.clonedeep "^4.5.0" - normalize-package-data "^2.4.0" - npm-package-arg "^6.1.0" - semver "^5.5.1" - ssri "^6.0.1" - -"@evocateur/npm-registry-fetch@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@evocateur/npm-registry-fetch/-/npm-registry-fetch-4.0.0.tgz#8c4c38766d8d32d3200fcb0a83f064b57365ed66" - integrity sha512-k1WGfKRQyhJpIr+P17O5vLIo2ko1PFLKwoetatdduUSt/aQ4J2sJrJwwatdI5Z3SiYk/mRH9S3JpdmMFd/IK4g== - dependencies: - JSONStream "^1.3.4" - bluebird "^3.5.1" - figgy-pudding "^3.4.1" - lru-cache "^5.1.1" - make-fetch-happen "^5.0.0" - npm-package-arg "^6.1.0" - safe-buffer "^5.1.2" - -"@evocateur/pacote@^9.6.3": - version "9.6.5" - resolved "https://registry.npmjs.org/@evocateur/pacote/-/pacote-9.6.5.tgz#33de32ba210b6f17c20ebab4d497efc6755f4ae5" - integrity sha512-EI552lf0aG2nOV8NnZpTxNo2PcXKPmDbF9K8eCBFQdIZwHNGN/mi815fxtmUMa2wTa1yndotICIDt/V0vpEx2w== - dependencies: - "@evocateur/npm-registry-fetch" "^4.0.0" - bluebird "^3.5.3" - cacache "^12.0.3" - chownr "^1.1.2" - figgy-pudding "^3.5.1" - get-stream "^4.1.0" - glob "^7.1.4" - infer-owner "^1.0.4" - lru-cache "^5.1.1" - make-fetch-happen "^5.0.0" - minimatch "^3.0.4" - minipass "^2.3.5" - mississippi "^3.0.0" - mkdirp "^0.5.1" - normalize-package-data "^2.5.0" - npm-package-arg "^6.1.0" - npm-packlist "^1.4.4" - npm-pick-manifest "^3.0.0" - osenv "^0.1.5" - promise-inflight "^1.0.1" - promise-retry "^1.1.1" - protoduck "^5.0.1" - rimraf "^2.6.3" - safe-buffer "^5.2.0" - semver "^5.7.0" - ssri "^6.0.1" - tar "^4.4.10" - unique-filename "^1.1.1" - which "^1.3.1" - "@fmvilas/pseudo-yaml-ast@^0.3.1": version "0.3.1" resolved "https://registry.npmjs.org/@fmvilas/pseudo-yaml-ast/-/pseudo-yaml-ast-0.3.1.tgz#66c5df2c2d76ba8571dc5bd98fda4d7dce6121de" @@ -1810,35 +2307,104 @@ 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" xcase "^2.0.1" +"@google-cloud/common@^3.5.0": + version "3.5.0" + resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.5.0.tgz#0959e769e8075a06eb0823cc567eef00fd0c2d02" + integrity sha512-10d7ZAvKhq47L271AqvHEd8KzJqGU45TY+rwM2Z3JHuB070FeTi7oJJd7elfrnKaEvaktw3hH2wKnRWxk/3oWQ== + dependencies: + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^2.0.0" + arrify "^2.0.1" + duplexify "^4.1.1" + ent "^2.2.0" + extend "^3.0.2" + google-auth-library "^6.1.1" + retry-request "^4.1.1" + teeny-request "^7.0.0" + +"@google-cloud/paginator@^3.0.0": + version "3.0.5" + resolved "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-3.0.5.tgz#9d6b96c421a89bd560c1bc2c197c7611ef21db6c" + integrity sha512-N4Uk4BT1YuskfRhKXBs0n9Lg2YTROZc6IMpkO/8DIHODtm5s3xY8K5vVBo23v/2XulY3azwITQlYWgT4GdLsUw== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^2.0.0": + version "2.0.1" + resolved "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-2.0.1.tgz#13350ee609346435c795bbfe133a08dfeab78d65" + integrity sha512-ZDG38U/Yy6Zr21LaR3BTiiLtpJl6RkPS/JwoRT453G+6Q1DhlV0waNf8Lfu+YVYGIIxgKnLayJRfYlFJfiI8iQ== + +"@google-cloud/promisify@^2.0.0": + version "2.0.3" + resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.3.tgz#f934b5cdc939e3c7039ff62b9caaf59a9d89e3a8" + integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw== + +"@google-cloud/storage@^5.6.0": + version "5.6.0" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.6.0.tgz#bc6925c7970c375212a4da21c123298fc9665dec" + integrity sha512-nLcym8IuCzy1O7tNTXNFuMHfX900sTM3kSTqbKe7oFSoKUiaIM+FHuuuDimMMlieY6StA1xYNPRFFHz57Nv8YQ== + dependencies: + "@google-cloud/common" "^3.5.0" + "@google-cloud/paginator" "^3.0.0" + "@google-cloud/promisify" "^2.0.0" + arrify "^2.0.0" + compressible "^2.0.12" + date-and-time "^0.14.0" + duplexify "^4.0.0" + extend "^3.0.2" + gaxios "^4.0.0" + gcs-resumable-upload "^3.1.0" + get-stream "^6.0.0" + hash-stream-validation "^0.2.2" + mime "^2.2.0" + mime-types "^2.0.8" + onetime "^5.1.0" + p-limit "^3.0.1" + pumpify "^2.0.0" + snakeize "^0.1.0" + stream-events "^1.0.1" + xdg-basedir "^4.0.0" + +"@graphiql/toolkit@^0.1.0": + version "0.1.1" + resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.1.1.tgz#a7da3ba460ceae27bcdc8f03831ca4f88f90f3d7" + integrity sha512-cvsuaPkOA6/TZOdqEdvzqr7i+or2STTpSsteyDkrUXrwftRnH9ZfiUwnHPyf0AC2cKMwpP/Dny/UTS6CLC8ZNQ== + dependencies: + "@n1ru4l/push-pull-async-iterable-iterator" "^2.0.1" + graphql-ws "^4.1.0" + meros "^1.1.2" + subscriptions-transport-ws "^0.9.18" + "@graphql-codegen/cli@^1.17.7": version "1.17.10" resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.17.10.tgz#efebf9b887fdb94dd26dbf3eb1950e832efcda0e" @@ -1917,42 +2483,42 @@ upper-case "2.0.1" "@graphql-codegen/typescript-resolvers@^1.17.7": - version "1.17.12" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.17.12.tgz#1f8f608d68b46780351c8224a61750b9b9307497" - integrity sha512-qICKxl06R1yCBh03cdyiOYlHTebQ1n/FOiQPCxo4bsiF6HfbrgpMS8fdGEZ8v+VBPxQFZZ7HQ2KcuPNsuw5R6g== + version "1.18.2" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.18.2.tgz#7513b92df7c5a0d3c27342c591ada7340696cf8f" + integrity sha512-aWfRR5y1gXCPUNK7zaUiSbmceqidvZ38mNHIBvXmZArRigyz1QZgN26kKpXjJjtFbPvROPnlGsYcZBReybvZXA== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/typescript" "^1.18.1" - "@graphql-codegen/visitor-plugin-common" "^1.17.20" - "@graphql-tools/utils" "^6" + "@graphql-codegen/typescript" "^1.21.0" + "@graphql-codegen/visitor-plugin-common" "^1.18.3" + "@graphql-tools/utils" "^7.0.0" auto-bind "~4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" -"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.18.1": - version "1.18.1" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.18.1.tgz#3d730472a01f18aea6331046f4ebfe3d91326801" - integrity sha512-Ee37NutKmaNrgAo2d5mv42RqPd8jJ6zyUKAH669Gbv0dFn2EK3sdC9PYQC9gXptv+H/eQn2gYgaa2nVpEPAIzg== +"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.21.0": + version "1.21.0" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.21.0.tgz#301b1851cd278bedd1f49e1b3d654f4dc0af2943" + integrity sha512-23YttnZ+87dA/3lbCvPKdsrpEOx142dCT9xSh6XkSeyCvn+vUtETN2MhamCYB87G7Nu2EcLDFKDZjgXH73f4fg== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/visitor-plugin-common" "^1.17.20" + "@graphql-codegen/visitor-plugin-common" "^1.18.3" auto-bind "~4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" -"@graphql-codegen/visitor-plugin-common@^1.17.20": - version "1.17.20" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.17.20.tgz#cff95cdd49cef270b3811fdb141a412ffe2bdfd7" - integrity sha512-buIpcNNyTqVubknancX8m9jARCZsUA5eKuskg+CylWKL/8CSaD2Tiq7CfbbNO10o7XIgRrPtJMl1c9hQ6N4ytw== +"@graphql-codegen/visitor-plugin-common@^1.18.3": + version "1.18.3" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.18.3.tgz#9d2c4449c3bdaffe3e782e2321fe0cb998b8a91d" + integrity sha512-6xJzt8hszCTKt3rTlcCURpuiAFuaiaZgStlVeRE1OrKEDiY1T3vwF3/7TonhfnEjqBWtZdMmXvNx3ArXkRUV4w== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" "@graphql-tools/optimize" "^1.0.1" "@graphql-tools/relay-operation-optimizer" "^6" array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" - dependency-graph "^0.9.0" + dependency-graph "^0.10.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" pascal-case "^3.1.1" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-modules/core@^0.7.17": version "0.7.17" @@ -2238,14 +2804,14 @@ camel-case "4.1.1" tslib "~2.0.1" -"@graphql-tools/utils@^7.1.0": - version "7.1.0" - resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.1.0.tgz#0cbcfa7b6e7741650f78e8bde4823780ed9e8c57" - integrity sha512-lMTgy08BdqQ31zyyCRrxcKZ6gAKQB9amGKJGg0mb3b4I3iJQKeaw9a7T96yGe1frGak1UK9y7OoYqx8RG7KitA== +"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.0": + version "7.2.6" + resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.2.6.tgz#5d974cbdec5ddf4d7fdc593816335512ee5fe4de" + integrity sha512-/kY7Nb+cCHi/MvU3tjz3KrXzuJNWMlnn7EoWazLmpDvl6b2Qt69hlVoPd5zQtKlGib35zZw9NZ5zs5qTAw8Y9g== dependencies: "@ardatan/aggregate-error" "0.0.6" - camel-case "4.1.1" - tslib "~2.0.1" + camel-case "4.1.2" + tslib "~2.1.0" "@graphql-tools/wrap@^6.2.4": version "6.2.4" @@ -2328,46 +2894,55 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jest/console@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/console/-/console-26.5.2.tgz#94fc4865b1abed7c352b5e21e6c57be4b95604a6" - integrity sha512-lJELzKINpF1v74DXHbCRIkQ/+nUV1M+ntj+X1J8LxCgpmJZjfLmhFejiMSbjjD66fayxl5Z06tbs3HMyuik6rw== +"@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/types" "^26.5.2" + "@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" + integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== + dependencies: + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^26.5.2" - jest-util "^26.5.2" + jest-message-util "^26.6.2" + jest-util "^26.6.2" slash "^3.0.0" -"@jest/core@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/core/-/core-26.5.3.tgz#712ed4adb64c3bda256a3f400ff1d3eb2a031f13" - integrity sha512-CiU0UKFF1V7KzYTVEtFbFmGLdb2g4aTtY0WlyUfLgj/RtoTnJFhh50xKKr7OYkdmBUlGFSa2mD1TU3UZ6OLd4g== +"@jest/core@^26.6.3": + version "26.6.3" + resolved "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" + integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== dependencies: - "@jest/console" "^26.5.2" - "@jest/reporters" "^26.5.3" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/reporters" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" - jest-changed-files "^26.5.2" - jest-config "^26.5.3" - jest-haste-map "^26.5.2" - jest-message-util "^26.5.2" + jest-changed-files "^26.6.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-resolve-dependencies "^26.5.3" - jest-runner "^26.5.3" - jest-runtime "^26.5.3" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" - jest-watcher "^26.5.2" + jest-resolve "^26.6.2" + jest-resolve-dependencies "^26.6.3" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + jest-watcher "^26.6.2" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" @@ -2379,47 +2954,47 @@ resolved "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-26.5.0.tgz#1d07947adc51ea17766d9f0ccf5a8d6ea94c47dc" integrity sha512-DJ+pEBUIqarrbv1W/C39f9YH0rJ4wsXZ/VC6JafJPlHW2HOucKceeaqTOQj9MEDQZjySxMLkOq5mfXZXNZcmWw== -"@jest/environment@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.5.2.tgz#eba3cfc698f6e03739628f699c28e8a07f5e65fe" - integrity sha512-YjhCD/Zhkz0/1vdlS/QN6QmuUdDkpgBdK4SdiVg4Y19e29g4VQYN5Xg8+YuHjdoWGY7wJHMxc79uDTeTOy9Ngw== +"@jest/environment@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" + integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== dependencies: - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" + jest-mock "^26.6.2" -"@jest/fake-timers@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.5.2.tgz#1291ac81680ceb0dc7daa1f92c059307eea6400a" - integrity sha512-09Hn5Oraqt36V1akxQeWMVL0fR9c6PnEhpgLaYvREXZJAh2H2Y+QLCsl0g7uMoJeoWJAuz4tozk1prbR1Fc1sw== +"@jest/fake-timers@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" + integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@sinonjs/fake-timers" "^6.0.1" "@types/node" "*" - jest-message-util "^26.5.2" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" -"@jest/globals@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.5.3.tgz#90769b40e0af3fa0b28f6d8c5bbe3712467243fd" - integrity sha512-7QztI0JC2CuB+Wx1VdnOUNeIGm8+PIaqngYsZXQCkH2QV0GFqzAYc9BZfU0nuqA6cbYrWh5wkuMzyii3P7deug== +"@jest/globals@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" + integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== dependencies: - "@jest/environment" "^26.5.2" - "@jest/types" "^26.5.2" - expect "^26.5.3" + "@jest/environment" "^26.6.2" + "@jest/types" "^26.6.2" + expect "^26.6.2" -"@jest/reporters@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.5.3.tgz#e810e9c2b670f33f1c09e9975749260ca12f1c17" - integrity sha512-X+vR0CpfMQzYcYmMFKNY9n4jklcb14Kffffp7+H/MqitWnb0440bW2L76NGWKAa+bnXhNoZr+lCVtdtPmfJVOQ== +"@jest/reporters@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" + integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^26.5.2" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" @@ -2430,80 +3005,97 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" - jest-haste-map "^26.5.2" - jest-resolve "^26.5.2" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-haste-map "^26.6.2" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" - v8-to-istanbul "^6.0.1" + v8-to-istanbul "^7.0.0" optionalDependencies: node-notifier "^8.0.0" -"@jest/source-map@^26.5.0": - version "26.5.0" - resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.5.0.tgz#98792457c85bdd902365cd2847b58fff05d96367" - integrity sha512-jWAw9ZwYHJMe9eZq/WrsHlwF8E3hM9gynlcDpOyCb9bR8wEd9ZNBZCi7/jZyzHxC7t3thZ10gO2IDhu0bPKS5g== +"@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" + integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.5.2.tgz#cc1a44cfd4db2ecee3fb0bc4e9fe087aa54b5230" - integrity sha512-E/Zp6LURJEGSCWpoMGmCFuuEI1OWuI3hmZwmULV0GsgJBh7u0rwqioxhRU95euUuviqBDN8ruX/vP/4bwYolXw== +"@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" "^26.5.2" - "@jest/types" "^26.5.2" + "@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" + integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== + dependencies: + "@jest/console" "^26.6.2" + "@jest/types" "^26.6.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^26.5.3": - version "26.5.3" - resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.5.3.tgz#9ae0ab9bc37d5171b28424029192e50229814f8d" - integrity sha512-Wqzb7aQ13L3T47xHdpUqYMOpiqz6Dx2QDDghp5AV/eUDXR7JieY+E1s233TQlNyl+PqtqgjVokmyjzX/HA51BA== +"@jest/test-sequencer@^26.6.3": + version "26.6.3" + resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" + integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== dependencies: - "@jest/test-result" "^26.5.2" + "@jest/test-result" "^26.6.2" graceful-fs "^4.2.4" - jest-haste-map "^26.5.2" - jest-runner "^26.5.3" - jest-runtime "^26.5.3" + jest-haste-map "^26.6.2" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" -"@jest/transform@^26.5.2": - version "26.5.2" - resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.5.2.tgz#6a0033a1d24316a1c75184d010d864f2c681bef5" - integrity sha512-AUNjvexh+APhhmS8S+KboPz+D3pCxPvEAGduffaAJYxIFxGi/ytZQkrqcKDUU0ERBAo5R7087fyOYr2oms1seg== +"@jest/transform@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" + integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" - jest-haste-map "^26.5.2" + jest-haste-map "^26.6.2" jest-regex-util "^26.0.0" - jest-util "^26.5.2" + jest-util "^26.6.2" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" -"@jest/types@^25.5.0": - version "25.5.0" - resolved "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" - integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw== +"@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" "^15.0.0" - chalk "^3.0.0" + "@types/yargs" "^13.0.0" -"@jest/types@^26.5.2", "@jest/types@^26.6.1": +"@jest/types@^26.6.1": version "26.6.1" resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== @@ -2514,15 +3106,26 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + "@jsdevtools/ono@^7.1.3": version "7.1.3" 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" @@ -2559,705 +3162,676 @@ 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== +"@lerna/add@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f" + integrity sha512-cpmAH1iS3k8JBxNvnMqrGTTjbY/ZAiKa1ChJzFevMYY3eeqbvhsBKnBcxjRXtdrJ6bd3dCQM+ZtK+0i682Fhng== 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" - integrity sha512-vhUXXF6SpufBE1EkNEXwz1VLW03f177G9uMOFMQkp6OJ30/PWg4Ekifuz9/3YfgB2/GH8Tu4Lk3O51P2Hskg/A== - dependencies: - "@evocateur/pacote" "^9.6.3" - "@lerna/bootstrap" "3.21.0" - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/npm-conf" "3.16.0" - "@lerna/validation-error" "3.13.0" + "@lerna/bootstrap" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/npm-conf" "4.0.0" + "@lerna/validation-error" "4.0.0" dedent "^0.7.0" - npm-package-arg "^6.1.0" - p-map "^2.1.0" - semver "^6.2.0" + npm-package-arg "^8.1.0" + p-map "^4.0.0" + pacote "^11.2.6" + semver "^7.3.4" -"@lerna/bootstrap@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.21.0.tgz#bcd1b651be5b0970b20d8fae04c864548123aed6" - integrity sha512-mtNHlXpmvJn6JTu0KcuTTPl2jLsDNud0QacV/h++qsaKbhAaJr/FElNZ5s7MwZFUM3XaDmvWzHKaszeBMHIbBw== +"@lerna/bootstrap@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-4.0.0.tgz#5f5c5e2c6cfc8fcec50cb2fbe569a8c607101891" + integrity sha512-RkS7UbeM2vu+kJnHzxNRCLvoOP9yGNgkzRdy4UV2hNalD7EP41bLvRVOwRYQ7fhc2QcbhnKNdOBihYRL0LcKtw== dependencies: - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/has-npm-version" "3.16.5" - "@lerna/npm-install" "3.16.5" - "@lerna/package-graph" "3.18.5" - "@lerna/pulse-till-done" "3.13.0" - "@lerna/rimraf-dir" "3.16.5" - "@lerna/run-lifecycle" "3.16.2" - "@lerna/run-topologically" "3.18.5" - "@lerna/symlink-binary" "3.17.0" - "@lerna/symlink-dependencies" "3.17.0" - "@lerna/validation-error" "3.13.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/has-npm-version" "4.0.0" + "@lerna/npm-install" "4.0.0" + "@lerna/package-graph" "4.0.0" + "@lerna/pulse-till-done" "4.0.0" + "@lerna/rimraf-dir" "4.0.0" + "@lerna/run-lifecycle" "4.0.0" + "@lerna/run-topologically" "4.0.0" + "@lerna/symlink-binary" "4.0.0" + "@lerna/symlink-dependencies" "4.0.0" + "@lerna/validation-error" "4.0.0" dedent "^0.7.0" - get-port "^4.2.0" - multimatch "^3.0.0" - npm-package-arg "^6.1.0" + get-port "^5.1.1" + multimatch "^5.0.0" + npm-package-arg "^8.1.0" npmlog "^4.1.2" - p-finally "^1.0.0" - p-map "^2.1.0" - p-map-series "^1.0.0" - p-waterfall "^1.0.0" - read-package-tree "^5.1.6" - semver "^6.2.0" + p-map "^4.0.0" + p-map-series "^2.1.0" + p-waterfall "^2.1.1" + read-package-tree "^5.3.1" + semver "^7.3.4" -"@lerna/changed@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/changed/-/changed-3.21.0.tgz#108e15f679bfe077af500f58248c634f1044ea0b" - integrity sha512-hzqoyf8MSHVjZp0gfJ7G8jaz+++mgXYiNs9iViQGA8JlN/dnWLI5sWDptEH3/B30Izo+fdVz0S0s7ydVE3pWIw== +"@lerna/changed@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/changed/-/changed-4.0.0.tgz#b9fc76cea39b9292a6cd263f03eb57af85c9270b" + integrity sha512-cD+KuPRp6qiPOD+BO6S6SN5cARspIaWSOqGBpGnYzLb4uWT8Vk4JzKyYtc8ym1DIwyoFXHosXt8+GDAgR8QrgQ== dependencies: - "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.21.0" - "@lerna/listable" "3.18.5" - "@lerna/output" "3.13.0" + "@lerna/collect-updates" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/listable" "4.0.0" + "@lerna/output" "4.0.0" -"@lerna/check-working-tree@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/check-working-tree/-/check-working-tree-3.16.5.tgz#b4f8ae61bb4523561dfb9f8f8d874dd46bb44baa" - integrity sha512-xWjVBcuhvB8+UmCSb5tKVLB5OuzSpw96WEhS2uz6hkWVa/Euh1A0/HJwn2cemyK47wUrCQXtczBUiqnq9yX5VQ== +"@lerna/check-working-tree@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/check-working-tree/-/check-working-tree-4.0.0.tgz#257e36a602c00142e76082a19358e3e1ae8dbd58" + integrity sha512-/++bxM43jYJCshBiKP5cRlCTwSJdRSxVmcDAXM+1oUewlZJVSVlnks5eO0uLxokVFvLhHlC5kHMc7gbVFPHv6Q== dependencies: - "@lerna/collect-uncommitted" "3.16.5" - "@lerna/describe-ref" "3.16.5" - "@lerna/validation-error" "3.13.0" + "@lerna/collect-uncommitted" "4.0.0" + "@lerna/describe-ref" "4.0.0" + "@lerna/validation-error" "4.0.0" -"@lerna/child-process@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/child-process/-/child-process-3.16.5.tgz#38fa3c18064aa4ac0754ad80114776a7b36a69b2" - integrity sha512-vdcI7mzei9ERRV4oO8Y1LHBZ3A5+ampRKg1wq5nutLsUA4mEBN6H7JqjWOMY9xZemv6+kATm2ofjJ3lW5TszQg== +"@lerna/child-process@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/child-process/-/child-process-4.0.0.tgz#341b96a57dffbd9705646d316e231df6fa4df6e1" + integrity sha512-XtCnmCT9eyVsUUHx6y/CTBYdV9g2Cr/VxyseTWBgfIur92/YKClfEtJTbOh94jRT62hlKLqSvux/UhxXVh613Q== dependencies: - chalk "^2.3.1" - execa "^1.0.0" - strong-log-transformer "^2.0.0" + chalk "^4.1.0" + execa "^5.0.0" + strong-log-transformer "^2.1.0" -"@lerna/clean@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/clean/-/clean-3.21.0.tgz#c0b46b5300cc3dae2cda3bec14b803082da3856d" - integrity sha512-b/L9l+MDgE/7oGbrav6rG8RTQvRiZLO1zTcG17zgJAAuhlsPxJExMlh2DFwJEVi2les70vMhHfST3Ue1IMMjpg== +"@lerna/clean@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/clean/-/clean-4.0.0.tgz#8f778b6f2617aa2a936a6b5e085ae62498e57dc5" + integrity sha512-uugG2iN9k45ITx2jtd8nEOoAtca8hNlDCUM0N3lFgU/b1mEQYAPRkqr1qs4FLRl/Y50ZJ41wUz1eazS+d/0osA== dependencies: - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/prompt" "3.18.5" - "@lerna/pulse-till-done" "3.13.0" - "@lerna/rimraf-dir" "3.16.5" - p-map "^2.1.0" - p-map-series "^1.0.0" - p-waterfall "^1.0.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/prompt" "4.0.0" + "@lerna/pulse-till-done" "4.0.0" + "@lerna/rimraf-dir" "4.0.0" + p-map "^4.0.0" + p-map-series "^2.1.0" + p-waterfall "^2.1.1" -"@lerna/cli@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/cli/-/cli-3.18.5.tgz#c90c461542fcd35b6d5b015a290fb0dbfb41d242" - integrity sha512-erkbxkj9jfc89vVs/jBLY/fM0I80oLmJkFUV3Q3wk9J3miYhP14zgVEBsPZY68IZlEjT6T3Xlq2xO1AVaatHsA== +"@lerna/cli@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/cli/-/cli-4.0.0.tgz#8eabd334558836c1664df23f19acb95e98b5bbf3" + integrity sha512-Neaw3GzFrwZiRZv2g7g6NwFjs3er1vhraIniEs0jjVLPMNC4eata0na3GfE5yibkM/9d3gZdmihhZdZ3EBdvYA== dependencies: - "@lerna/global-options" "3.13.0" + "@lerna/global-options" "4.0.0" dedent "^0.7.0" npmlog "^4.1.2" - yargs "^14.2.2" + yargs "^16.2.0" -"@lerna/collect-uncommitted@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/collect-uncommitted/-/collect-uncommitted-3.16.5.tgz#a494d61aac31cdc7aec4bbe52c96550274132e63" - integrity sha512-ZgqnGwpDZiWyzIQVZtQaj9tRizsL4dUOhuOStWgTAw1EMe47cvAY2kL709DzxFhjr6JpJSjXV5rZEAeU3VE0Hg== +"@lerna/collect-uncommitted@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/collect-uncommitted/-/collect-uncommitted-4.0.0.tgz#855cd64612969371cfc2453b90593053ff1ba779" + integrity sha512-ufSTfHZzbx69YNj7KXQ3o66V4RC76ffOjwLX0q/ab//61bObJ41n03SiQEhSlmpP+gmFbTJ3/7pTe04AHX9m/g== dependencies: - "@lerna/child-process" "3.16.5" - chalk "^2.3.1" - figgy-pudding "^3.5.1" + "@lerna/child-process" "4.0.0" + chalk "^4.1.0" npmlog "^4.1.2" -"@lerna/collect-updates@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/collect-updates/-/collect-updates-3.20.0.tgz#62f9d76ba21a25b7d9fbf31c02de88744a564bd1" - integrity sha512-qBTVT5g4fupVhBFuY4nI/3FSJtQVcDh7/gEPOpRxoXB/yCSnT38MFHXWl+y4einLciCjt/+0x6/4AG80fjay2Q== +"@lerna/collect-updates@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/collect-updates/-/collect-updates-4.0.0.tgz#8e208b1bafd98a372ff1177f7a5e288f6bea8041" + integrity sha512-bnNGpaj4zuxsEkyaCZLka9s7nMs58uZoxrRIPJ+nrmrZYp1V5rrd+7/NYTuunOhY2ug1sTBvTAxj3NZQ+JKnOw== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/describe-ref" "3.16.5" + "@lerna/child-process" "4.0.0" + "@lerna/describe-ref" "4.0.0" minimatch "^3.0.4" npmlog "^4.1.2" - slash "^2.0.0" + slash "^3.0.0" -"@lerna/command@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/command/-/command-3.21.0.tgz#9a2383759dc7b700dacfa8a22b2f3a6e190121f7" - integrity sha512-T2bu6R8R3KkH5YoCKdutKv123iUgUbW8efVjdGCDnCMthAQzoentOJfDeodBwn0P2OqCl3ohsiNVtSn9h78fyQ== +"@lerna/command@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/command/-/command-4.0.0.tgz#991c7971df8f5bf6ae6e42c808869a55361c1b98" + integrity sha512-LM9g3rt5FsPNFqIHUeRwWXLNHJ5NKzOwmVKZ8anSp4e1SPrv2HNc1V02/9QyDDZK/w+5POXH5lxZUI1CHaOK/A== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/package-graph" "3.18.5" - "@lerna/project" "3.21.0" - "@lerna/validation-error" "3.13.0" - "@lerna/write-log-file" "3.13.0" + "@lerna/child-process" "4.0.0" + "@lerna/package-graph" "4.0.0" + "@lerna/project" "4.0.0" + "@lerna/validation-error" "4.0.0" + "@lerna/write-log-file" "4.0.0" clone-deep "^4.0.1" dedent "^0.7.0" - execa "^1.0.0" + execa "^5.0.0" is-ci "^2.0.0" npmlog "^4.1.2" -"@lerna/conventional-commits@3.22.0": - version "3.22.0" - resolved "https://registry.npmjs.org/@lerna/conventional-commits/-/conventional-commits-3.22.0.tgz#2798f4881ee2ef457bdae027ab7d0bf0af6f1e09" - integrity sha512-z4ZZk1e8Mhz7+IS8NxHr64wyklHctCJyWpJKEZZPJiLFJ8yKto/x38O80R10pIzC0rr8Sy/OsjSH4bl0TbbgqA== +"@lerna/conventional-commits@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/conventional-commits/-/conventional-commits-4.0.0.tgz#660fb2c7b718cb942ead70110df61f18c6f99750" + integrity sha512-CSUQRjJHFrH8eBn7+wegZLV3OrNc0Y1FehYfYGhjLE2SIfpCL4bmfu/ViYuHh9YjwHaA+4SX6d3hR+xkeseKmw== dependencies: - "@lerna/validation-error" "3.13.0" - conventional-changelog-angular "^5.0.3" - conventional-changelog-core "^3.1.6" - conventional-recommended-bump "^5.0.0" - fs-extra "^8.1.0" - get-stream "^4.0.0" + "@lerna/validation-error" "4.0.0" + conventional-changelog-angular "^5.0.12" + conventional-changelog-core "^4.2.2" + conventional-recommended-bump "^6.1.0" + fs-extra "^9.1.0" + get-stream "^6.0.0" lodash.template "^4.5.0" - npm-package-arg "^6.1.0" + npm-package-arg "^8.1.0" npmlog "^4.1.2" - pify "^4.0.1" - semver "^6.2.0" + pify "^5.0.0" + semver "^7.3.4" -"@lerna/create-symlink@3.16.2": - version "3.16.2" - resolved "https://registry.npmjs.org/@lerna/create-symlink/-/create-symlink-3.16.2.tgz#412cb8e59a72f5a7d9463e4e4721ad2070149967" - integrity sha512-pzXIJp6av15P325sgiIRpsPXLFmkisLhMBCy4764d+7yjf2bzrJ4gkWVMhsv4AdF0NN3OyZ5jjzzTtLNqfR+Jw== +"@lerna/create-symlink@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/create-symlink/-/create-symlink-4.0.0.tgz#8c5317ce5ae89f67825443bd7651bf4121786228" + integrity sha512-I0phtKJJdafUiDwm7BBlEUOtogmu8+taxq6PtIrxZbllV9hWg59qkpuIsiFp+no7nfRVuaasNYHwNUhDAVQBig== dependencies: - "@zkochan/cmd-shim" "^3.1.0" - fs-extra "^8.1.0" + cmd-shim "^4.1.0" + fs-extra "^9.1.0" npmlog "^4.1.2" -"@lerna/create@3.22.0": - version "3.22.0" - resolved "https://registry.npmjs.org/@lerna/create/-/create-3.22.0.tgz#d6bbd037c3dc5b425fe5f6d1b817057c278f7619" - integrity sha512-MdiQQzCcB4E9fBF1TyMOaAEz9lUjIHp1Ju9H7f3lXze5JK6Fl5NYkouAvsLgY6YSIhXMY8AHW2zzXeBDY4yWkw== +"@lerna/create@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/create/-/create-4.0.0.tgz#b6947e9b5dfb6530321952998948c3e63d64d730" + integrity sha512-mVOB1niKByEUfxlbKTM1UNECWAjwUdiioIbRQZEeEabtjCL69r9rscIsjlGyhGWCfsdAG5wfq4t47nlDXdLLag== dependencies: - "@evocateur/pacote" "^9.6.3" - "@lerna/child-process" "3.16.5" - "@lerna/command" "3.21.0" - "@lerna/npm-conf" "3.16.0" - "@lerna/validation-error" "3.13.0" - camelcase "^5.0.0" + "@lerna/child-process" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/npm-conf" "4.0.0" + "@lerna/validation-error" "4.0.0" dedent "^0.7.0" - fs-extra "^8.1.0" - globby "^9.2.0" - init-package-json "^1.10.3" - npm-package-arg "^6.1.0" - p-reduce "^1.0.0" - pify "^4.0.1" - semver "^6.2.0" - slash "^2.0.0" - validate-npm-package-license "^3.0.3" + fs-extra "^9.1.0" + globby "^11.0.2" + init-package-json "^2.0.2" + npm-package-arg "^8.1.0" + p-reduce "^2.1.0" + pacote "^11.2.6" + pify "^5.0.0" + semver "^7.3.4" + slash "^3.0.0" + validate-npm-package-license "^3.0.4" validate-npm-package-name "^3.0.0" - whatwg-url "^7.0.0" + whatwg-url "^8.4.0" + yargs-parser "20.2.4" -"@lerna/describe-ref@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/describe-ref/-/describe-ref-3.16.5.tgz#a338c25aaed837d3dc70b8a72c447c5c66346ac0" - integrity sha512-c01+4gUF0saOOtDBzbLMFOTJDHTKbDFNErEY6q6i9QaXuzy9LNN62z+Hw4acAAZuJQhrVWncVathcmkkjvSVGw== +"@lerna/describe-ref@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/describe-ref/-/describe-ref-4.0.0.tgz#53c53b4ea65fdceffa072a62bfebe6772c45d9ec" + integrity sha512-eTU5+xC4C5Gcgz+Ey4Qiw9nV2B4JJbMulsYJMW8QjGcGh8zudib7Sduj6urgZXUYNyhYpRs+teci9M2J8u+UvQ== dependencies: - "@lerna/child-process" "3.16.5" + "@lerna/child-process" "4.0.0" npmlog "^4.1.2" -"@lerna/diff@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/diff/-/diff-3.21.0.tgz#e6df0d8b9916167ff5a49fcb02ac06424280a68d" - integrity sha512-5viTR33QV3S7O+bjruo1SaR40m7F2aUHJaDAC7fL9Ca6xji+aw1KFkpCtVlISS0G8vikUREGMJh+c/VMSc8Usw== +"@lerna/diff@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/diff/-/diff-4.0.0.tgz#6d3071817aaa4205a07bf77cfc6e932796d48b92" + integrity sha512-jYPKprQVg41+MUMxx6cwtqsNm0Yxx9GDEwdiPLwcUTFx+/qKCEwifKNJ1oGIPBxyEHX2PFCOjkK39lHoj2qiag== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/command" "3.21.0" - "@lerna/validation-error" "3.13.0" + "@lerna/child-process" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/validation-error" "4.0.0" npmlog "^4.1.2" -"@lerna/exec@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/exec/-/exec-3.21.0.tgz#17f07533893cb918a17b41bcc566dc437016db26" - integrity sha512-iLvDBrIE6rpdd4GIKTY9mkXyhwsJ2RvQdB9ZU+/NhR3okXfqKc6py/24tV111jqpXTtZUW6HNydT4dMao2hi1Q== +"@lerna/exec@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/exec/-/exec-4.0.0.tgz#eb6cb95cb92d42590e9e2d628fcaf4719d4a8be6" + integrity sha512-VGXtL/b/JfY84NB98VWZpIExfhLOzy0ozm/0XaS4a2SmkAJc5CeUfrhvHxxkxiTBLkU+iVQUyYEoAT0ulQ8PCw== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/profiler" "3.20.0" - "@lerna/run-topologically" "3.18.5" - "@lerna/validation-error" "3.13.0" - p-map "^2.1.0" + "@lerna/child-process" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/profiler" "4.0.0" + "@lerna/run-topologically" "4.0.0" + "@lerna/validation-error" "4.0.0" + p-map "^4.0.0" -"@lerna/filter-options@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/filter-options/-/filter-options-3.20.0.tgz#0f0f5d5a4783856eece4204708cc902cbc8af59b" - integrity sha512-bmcHtvxn7SIl/R9gpiNMVG7yjx7WyT0HSGw34YVZ9B+3xF/83N3r5Rgtjh4hheLZ+Q91Or0Jyu5O3Nr+AwZe2g== +"@lerna/filter-options@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/filter-options/-/filter-options-4.0.0.tgz#ac94cc515d7fa3b47e2f7d74deddeabb1de5e9e6" + integrity sha512-vV2ANOeZhOqM0rzXnYcFFCJ/kBWy/3OA58irXih9AMTAlQLymWAK0akWybl++sUJ4HB9Hx12TOqaXbYS2NM5uw== dependencies: - "@lerna/collect-updates" "3.20.0" - "@lerna/filter-packages" "3.18.0" + "@lerna/collect-updates" "4.0.0" + "@lerna/filter-packages" "4.0.0" dedent "^0.7.0" - figgy-pudding "^3.5.1" npmlog "^4.1.2" -"@lerna/filter-packages@3.18.0": - version "3.18.0" - resolved "https://registry.npmjs.org/@lerna/filter-packages/-/filter-packages-3.18.0.tgz#6a7a376d285208db03a82958cfb8172e179b4e70" - integrity sha512-6/0pMM04bCHNATIOkouuYmPg6KH3VkPCIgTfQmdkPJTullERyEQfNUKikrefjxo1vHOoCACDpy65JYyKiAbdwQ== +"@lerna/filter-packages@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/filter-packages/-/filter-packages-4.0.0.tgz#b1f70d70e1de9cdd36a4e50caa0ac501f8d012f2" + integrity sha512-+4AJIkK7iIiOaqCiVTYJxh/I9qikk4XjNQLhE3kixaqgMuHl1NQ99qXRR0OZqAWB9mh8Z1HA9bM5K1HZLBTOqA== dependencies: - "@lerna/validation-error" "3.13.0" - multimatch "^3.0.0" + "@lerna/validation-error" "4.0.0" + multimatch "^5.0.0" npmlog "^4.1.2" -"@lerna/get-npm-exec-opts@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/get-npm-exec-opts/-/get-npm-exec-opts-3.13.0.tgz#d1b552cb0088199fc3e7e126f914e39a08df9ea5" - integrity sha512-Y0xWL0rg3boVyJk6An/vurKzubyJKtrxYv2sj4bB8Mc5zZ3tqtv0ccbOkmkXKqbzvNNF7VeUt1OJ3DRgtC/QZw== +"@lerna/get-npm-exec-opts@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/get-npm-exec-opts/-/get-npm-exec-opts-4.0.0.tgz#dc955be94a4ae75c374ef9bce91320887d34608f" + integrity sha512-yvmkerU31CTWS2c7DvmAWmZVeclPBqI7gPVr5VATUKNWJ/zmVcU4PqbYoLu92I9Qc4gY1TuUplMNdNuZTSL7IQ== dependencies: npmlog "^4.1.2" -"@lerna/get-packed@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/get-packed/-/get-packed-3.16.0.tgz#1b316b706dcee86c7baa55e50b087959447852ff" - integrity sha512-AjsFiaJzo1GCPnJUJZiTW6J1EihrPkc2y3nMu6m3uWFxoleklsSCyImumzVZJssxMi3CPpztj8LmADLedl9kXw== +"@lerna/get-packed@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/get-packed/-/get-packed-4.0.0.tgz#0989d61624ac1f97e393bdad2137c49cd7a37823" + integrity sha512-rfWONRsEIGyPJTxFzC8ECb3ZbsDXJbfqWYyeeQQDrJRPnEJErlltRLPLgC2QWbxFgFPsoDLeQmFHJnf0iDfd8w== dependencies: - fs-extra "^8.1.0" - ssri "^6.0.1" - tar "^4.4.8" + fs-extra "^9.1.0" + ssri "^8.0.1" + tar "^6.1.0" -"@lerna/github-client@3.22.0": - version "3.22.0" - resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.22.0.tgz#5d816aa4f76747ed736ae64ff962b8f15c354d95" - integrity sha512-O/GwPW+Gzr3Eb5bk+nTzTJ3uv+jh5jGho9BOqKlajXaOkMYGBELEAqV5+uARNGWZFvYAiF4PgqHb6aCUu7XdXg== +"@lerna/github-client@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-4.0.0.tgz#2ced67721363ef70f8e12ffafce4410918f4a8a4" + integrity sha512-2jhsldZtTKXYUBnOm23Lb0Fx8G4qfSXF9y7UpyUgWUj+YZYd+cFxSuorwQIgk5P4XXrtVhsUesIsli+BYSThiw== dependencies: - "@lerna/child-process" "3.16.5" + "@lerna/child-process" "4.0.0" "@octokit/plugin-enterprise-rest" "^6.0.1" - "@octokit/rest" "^16.28.4" - git-url-parse "^11.1.2" + "@octokit/rest" "^18.1.0" + git-url-parse "^11.4.4" npmlog "^4.1.2" -"@lerna/gitlab-client@3.15.0": - version "3.15.0" - resolved "https://registry.npmjs.org/@lerna/gitlab-client/-/gitlab-client-3.15.0.tgz#91f4ec8c697b5ac57f7f25bd50fe659d24aa96a6" - integrity sha512-OsBvRSejHXUBMgwWQqNoioB8sgzL/Pf1pOUhHKtkiMl6aAWjklaaq5HPMvTIsZPfS6DJ9L5OK2GGZuooP/5c8Q== +"@lerna/gitlab-client@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/gitlab-client/-/gitlab-client-4.0.0.tgz#00dad73379c7b38951d4b4ded043504c14e2b67d" + integrity sha512-OMUpGSkeDWFf7BxGHlkbb35T7YHqVFCwBPSIR6wRsszY8PAzCYahtH3IaJzEJyUg6vmZsNl0FSr3pdA2skhxqA== dependencies: - node-fetch "^2.5.0" + node-fetch "^2.6.1" npmlog "^4.1.2" - whatwg-url "^7.0.0" + whatwg-url "^8.4.0" -"@lerna/global-options@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/global-options/-/global-options-3.13.0.tgz#217662290db06ad9cf2c49d8e3100ee28eaebae1" - integrity sha512-SlZvh1gVRRzYLVluz9fryY1nJpZ0FHDGB66U9tFfvnnxmueckRQxLopn3tXj3NU1kc3QANT2I5BsQkOqZ4TEFQ== +"@lerna/global-options@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/global-options/-/global-options-4.0.0.tgz#c7d8b0de6a01d8a845e2621ea89e7f60f18c6a5f" + integrity sha512-TRMR8afAHxuYBHK7F++Ogop2a82xQjoGna1dvPOY6ltj/pEx59pdgcJfYcynYqMkFIk8bhLJJN9/ndIfX29FTQ== -"@lerna/has-npm-version@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/has-npm-version/-/has-npm-version-3.16.5.tgz#ab83956f211d8923ea6afe9b979b38cc73b15326" - integrity sha512-WL7LycR9bkftyqbYop5rEGJ9sRFIV55tSGmbN1HLrF9idwOCD7CLrT64t235t3t4O5gehDnwKI5h2U3oxTrF8Q== +"@lerna/has-npm-version@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/has-npm-version/-/has-npm-version-4.0.0.tgz#d3fc3292c545eb28bd493b36e6237cf0279f631c" + integrity sha512-LQ3U6XFH8ZmLCsvsgq1zNDqka0Xzjq5ibVN+igAI5ccRWNaUsE/OcmsyMr50xAtNQMYMzmpw5GVLAivT2/YzCg== dependencies: - "@lerna/child-process" "3.16.5" - semver "^6.2.0" + "@lerna/child-process" "4.0.0" + semver "^7.3.4" -"@lerna/import@3.22.0": - version "3.22.0" - resolved "https://registry.npmjs.org/@lerna/import/-/import-3.22.0.tgz#1a5f0394f38e23c4f642a123e5e1517e70d068d2" - integrity sha512-uWOlexasM5XR6tXi4YehODtH9Y3OZrFht3mGUFFT3OIl2s+V85xIGFfqFGMTipMPAGb2oF1UBLL48kR43hRsOg== +"@lerna/import@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/import/-/import-4.0.0.tgz#bde656c4a451fa87ae41733ff8a8da60547c5465" + integrity sha512-FaIhd+4aiBousKNqC7TX1Uhe97eNKf5/SC7c5WZANVWtC7aBWdmswwDt3usrzCNpj6/Wwr9EtEbYROzxKH8ffg== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/command" "3.21.0" - "@lerna/prompt" "3.18.5" - "@lerna/pulse-till-done" "3.13.0" - "@lerna/validation-error" "3.13.0" + "@lerna/child-process" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/prompt" "4.0.0" + "@lerna/pulse-till-done" "4.0.0" + "@lerna/validation-error" "4.0.0" dedent "^0.7.0" - fs-extra "^8.1.0" - p-map-series "^1.0.0" + fs-extra "^9.1.0" + p-map-series "^2.1.0" -"@lerna/info@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/info/-/info-3.21.0.tgz#76696b676fdb0f35d48c83c63c1e32bb5e37814f" - integrity sha512-0XDqGYVBgWxUquFaIptW2bYSIu6jOs1BtkvRTWDDhw4zyEdp6q4eaMvqdSap1CG+7wM5jeLCi6z94wS0AuiuwA== +"@lerna/info@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/info/-/info-4.0.0.tgz#b9fb0e479d60efe1623603958a831a88b1d7f1fc" + integrity sha512-8Uboa12kaCSZEn4XRfPz5KU9XXoexSPS4oeYGj76s2UQb1O1GdnEyfjyNWoUl1KlJ2i/8nxUskpXIftoFYH0/Q== dependencies: - "@lerna/command" "3.21.0" - "@lerna/output" "3.13.0" - envinfo "^7.3.1" + "@lerna/command" "4.0.0" + "@lerna/output" "4.0.0" + envinfo "^7.7.4" -"@lerna/init@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/init/-/init-3.21.0.tgz#1e810934dc8bf4e5386c031041881d3b4096aa5c" - integrity sha512-6CM0z+EFUkFfurwdJCR+LQQF6MqHbYDCBPyhu/d086LRf58GtYZYj49J8mKG9ktayp/TOIxL/pKKjgLD8QBPOg== +"@lerna/init@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/init/-/init-4.0.0.tgz#dadff67e6dfb981e8ccbe0e6a310e837962f6c7a" + integrity sha512-wY6kygop0BCXupzWj5eLvTUqdR7vIAm0OgyV9WHpMYQGfs1V22jhztt8mtjCloD/O0nEe4tJhdG62XU5aYmPNQ== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/command" "3.21.0" - fs-extra "^8.1.0" - p-map "^2.1.0" - write-json-file "^3.2.0" + "@lerna/child-process" "4.0.0" + "@lerna/command" "4.0.0" + fs-extra "^9.1.0" + p-map "^4.0.0" + write-json-file "^4.3.0" -"@lerna/link@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/link/-/link-3.21.0.tgz#8be68ff0ccee104b174b5bbd606302c2f06e9d9b" - integrity sha512-tGu9GxrX7Ivs+Wl3w1+jrLi1nQ36kNI32dcOssij6bg0oZ2M2MDEFI9UF2gmoypTaN9uO5TSsjCFS7aR79HbdQ== +"@lerna/link@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/link/-/link-4.0.0.tgz#c3a38aabd44279d714e90f2451e31b63f0fb65ba" + integrity sha512-KlvPi7XTAcVOByfaLlOeYOfkkDcd+bejpHMCd1KcArcFTwijOwXOVi24DYomIeHvy6HsX/IUquJ4PPUJIeB4+w== dependencies: - "@lerna/command" "3.21.0" - "@lerna/package-graph" "3.18.5" - "@lerna/symlink-dependencies" "3.17.0" - p-map "^2.1.0" - slash "^2.0.0" + "@lerna/command" "4.0.0" + "@lerna/package-graph" "4.0.0" + "@lerna/symlink-dependencies" "4.0.0" + p-map "^4.0.0" + slash "^3.0.0" -"@lerna/list@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/list/-/list-3.21.0.tgz#42f76fafa56dea13b691ec8cab13832691d61da2" - integrity sha512-KehRjE83B1VaAbRRkRy6jLX1Cin8ltsrQ7FHf2bhwhRHK0S54YuA6LOoBnY/NtA8bHDX/Z+G5sMY78X30NS9tg== +"@lerna/list@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/list/-/list-4.0.0.tgz#24b4e6995bd73f81c556793fe502b847efd9d1d7" + integrity sha512-L2B5m3P+U4Bif5PultR4TI+KtW+SArwq1i75QZ78mRYxPc0U/piau1DbLOmwrdqr99wzM49t0Dlvl6twd7GHFg== dependencies: - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/listable" "3.18.5" - "@lerna/output" "3.13.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/listable" "4.0.0" + "@lerna/output" "4.0.0" -"@lerna/listable@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/listable/-/listable-3.18.5.tgz#e82798405b5ed8fc51843c8ef1e7a0e497388a1a" - integrity sha512-Sdr3pVyaEv5A7ZkGGYR7zN+tTl2iDcinryBPvtuv20VJrXBE8wYcOks1edBTcOWsPjCE/rMP4bo1pseyk3UTsg== +"@lerna/listable@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/listable/-/listable-4.0.0.tgz#d00d6cb4809b403f2b0374fc521a78e318b01214" + integrity sha512-/rPOSDKsOHs5/PBLINZOkRIX1joOXUXEtyUs5DHLM8q6/RP668x/1lFhw6Dx7/U+L0+tbkpGtZ1Yt0LewCLgeQ== dependencies: - "@lerna/query-graph" "3.18.5" - chalk "^2.3.1" + "@lerna/query-graph" "4.0.0" + chalk "^4.1.0" columnify "^1.5.4" -"@lerna/log-packed@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/log-packed/-/log-packed-3.16.0.tgz#f83991041ee77b2495634e14470b42259fd2bc16" - integrity sha512-Fp+McSNBV/P2mnLUYTaSlG8GSmpXM7krKWcllqElGxvAqv6chk2K3c2k80MeVB4WvJ9tRjUUf+i7HUTiQ9/ckQ== +"@lerna/log-packed@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/log-packed/-/log-packed-4.0.0.tgz#95168fe2e26ac6a71e42f4be857519b77e57a09f" + integrity sha512-+dpCiWbdzgMAtpajLToy9PO713IHoE6GV/aizXycAyA07QlqnkpaBNZ8DW84gHdM1j79TWockGJo9PybVhrrZQ== dependencies: - byte-size "^5.0.1" + byte-size "^7.0.0" columnify "^1.5.4" has-unicode "^2.0.1" npmlog "^4.1.2" -"@lerna/npm-conf@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/npm-conf/-/npm-conf-3.16.0.tgz#1c10a89ae2f6c2ee96962557738685300d376827" - integrity sha512-HbO3DUrTkCAn2iQ9+FF/eisDpWY5POQAOF1m7q//CZjdC2HSW3UYbKEGsSisFxSfaF9Z4jtrV+F/wX6qWs3CuA== +"@lerna/npm-conf@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/npm-conf/-/npm-conf-4.0.0.tgz#b259fd1e1cee2bf5402b236e770140ff9ade7fd2" + integrity sha512-uS7H02yQNq3oejgjxAxqq/jhwGEE0W0ntr8vM3EfpCW1F/wZruwQw+7bleJQ9vUBjmdXST//tk8mXzr5+JXCfw== dependencies: - config-chain "^1.1.11" - pify "^4.0.1" + config-chain "^1.1.12" + pify "^5.0.0" -"@lerna/npm-dist-tag@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/npm-dist-tag/-/npm-dist-tag-3.18.5.tgz#9ef9abb7c104077b31f6fab22cc73b314d54ac55" - integrity sha512-xw0HDoIG6HreVsJND9/dGls1c+lf6vhu7yJoo56Sz5bvncTloYGLUppIfDHQr4ZvmPCK8rsh0euCVh2giPxzKQ== +"@lerna/npm-dist-tag@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/npm-dist-tag/-/npm-dist-tag-4.0.0.tgz#d1e99b4eccd3414142f0548ad331bf2d53f3257a" + integrity sha512-F20sg28FMYTgXqEQihgoqSfwmq+Id3zT23CnOwD+XQMPSy9IzyLf1fFVH319vXIw6NF6Pgs4JZN2Qty6/CQXGw== dependencies: - "@evocateur/npm-registry-fetch" "^4.0.0" - "@lerna/otplease" "3.18.5" - figgy-pudding "^3.5.1" - npm-package-arg "^6.1.0" + "@lerna/otplease" "4.0.0" + npm-package-arg "^8.1.0" + npm-registry-fetch "^9.0.0" npmlog "^4.1.2" -"@lerna/npm-install@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/npm-install/-/npm-install-3.16.5.tgz#d6bfdc16f81285da66515ae47924d6e278d637d3" - integrity sha512-hfiKk8Eku6rB9uApqsalHHTHY+mOrrHeWEs+gtg7+meQZMTS3kzv4oVp5cBZigndQr3knTLjwthT/FX4KvseFg== +"@lerna/npm-install@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/npm-install/-/npm-install-4.0.0.tgz#31180be3ab3b7d1818a1a0c206aec156b7094c78" + integrity sha512-aKNxq2j3bCH3eXl3Fmu4D54s/YLL9WSwV8W7X2O25r98wzrO38AUN6AB9EtmAx+LV/SP15et7Yueg9vSaanRWg== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/get-npm-exec-opts" "3.13.0" - fs-extra "^8.1.0" - npm-package-arg "^6.1.0" + "@lerna/child-process" "4.0.0" + "@lerna/get-npm-exec-opts" "4.0.0" + fs-extra "^9.1.0" + npm-package-arg "^8.1.0" npmlog "^4.1.2" - signal-exit "^3.0.2" - write-pkg "^3.1.0" + signal-exit "^3.0.3" + write-pkg "^4.0.0" -"@lerna/npm-publish@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/npm-publish/-/npm-publish-3.18.5.tgz#240e4039959fd9816b49c5b07421e11b5cb000af" - integrity sha512-3etLT9+2L8JAx5F8uf7qp6iAtOLSMj+ZYWY6oUgozPi/uLqU0/gsMsEXh3F0+YVW33q0M61RpduBoAlOOZnaTg== +"@lerna/npm-publish@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/npm-publish/-/npm-publish-4.0.0.tgz#84eb62e876fe949ae1fd62c60804423dbc2c4472" + integrity sha512-vQb7yAPRo5G5r77DRjHITc9piR9gvEKWrmfCH7wkfBnGWEqu7n8/4bFQ7lhnkujvc8RXOsYpvbMQkNfkYibD/w== dependencies: - "@evocateur/libnpmpublish" "^1.2.2" - "@lerna/otplease" "3.18.5" - "@lerna/run-lifecycle" "3.16.2" - figgy-pudding "^3.5.1" - fs-extra "^8.1.0" - npm-package-arg "^6.1.0" + "@lerna/otplease" "4.0.0" + "@lerna/run-lifecycle" "4.0.0" + fs-extra "^9.1.0" + libnpmpublish "^4.0.0" + npm-package-arg "^8.1.0" npmlog "^4.1.2" - pify "^4.0.1" - read-package-json "^2.0.13" + pify "^5.0.0" + read-package-json "^3.0.0" -"@lerna/npm-run-script@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/npm-run-script/-/npm-run-script-3.16.5.tgz#9c2ec82453a26c0b46edc0bb7c15816c821f5c15" - integrity sha512-1asRi+LjmVn3pMjEdpqKJZFT/3ZNpb+VVeJMwrJaV/3DivdNg7XlPK9LTrORuKU4PSvhdEZvJmSlxCKyDpiXsQ== +"@lerna/npm-run-script@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/npm-run-script/-/npm-run-script-4.0.0.tgz#dfebf4f4601442e7c0b5214f9fb0d96c9350743b" + integrity sha512-Jmyh9/IwXJjOXqKfIgtxi0bxi1pUeKe5bD3S81tkcy+kyng/GNj9WSqD5ZggoNP2NP//s4CLDAtUYLdP7CU9rA== dependencies: - "@lerna/child-process" "3.16.5" - "@lerna/get-npm-exec-opts" "3.13.0" + "@lerna/child-process" "4.0.0" + "@lerna/get-npm-exec-opts" "4.0.0" npmlog "^4.1.2" -"@lerna/otplease@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/otplease/-/otplease-3.18.5.tgz#b77b8e760b40abad9f7658d988f3ea77d4fd0231" - integrity sha512-S+SldXAbcXTEDhzdxYLU0ZBKuYyURP/ND2/dK6IpKgLxQYh/z4ScljPDMyKymmEvgiEJmBsPZAAPfmNPEzxjog== +"@lerna/otplease@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/otplease/-/otplease-4.0.0.tgz#84972eb43448f8a1077435ba1c5e59233b725850" + integrity sha512-Sgzbqdk1GH4psNiT6hk+BhjOfIr/5KhGBk86CEfHNJTk9BK4aZYyJD4lpDbDdMjIV4g03G7pYoqHzH765T4fxw== dependencies: - "@lerna/prompt" "3.18.5" - figgy-pudding "^3.5.1" + "@lerna/prompt" "4.0.0" -"@lerna/output@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/output/-/output-3.13.0.tgz#3ded7cc908b27a9872228a630d950aedae7a4989" - integrity sha512-7ZnQ9nvUDu/WD+bNsypmPG5MwZBwu86iRoiW6C1WBuXXDxM5cnIAC1m2WxHeFnjyMrYlRXM9PzOQ9VDD+C15Rg== +"@lerna/output@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/output/-/output-4.0.0.tgz#b1d72215c0e35483e4f3e9994debc82c621851f2" + integrity sha512-Un1sHtO1AD7buDQrpnaYTi2EG6sLF+KOPEAMxeUYG5qG3khTs2Zgzq5WE3dt2N/bKh7naESt20JjIW6tBELP0w== dependencies: npmlog "^4.1.2" -"@lerna/pack-directory@3.16.4": - version "3.16.4" - resolved "https://registry.npmjs.org/@lerna/pack-directory/-/pack-directory-3.16.4.tgz#3eae5f91bdf5acfe0384510ed53faddc4c074693" - integrity sha512-uxSF0HZeGyKaaVHz5FroDY9A5NDDiCibrbYR6+khmrhZtY0Bgn6hWq8Gswl9iIlymA+VzCbshWIMX4o2O8C8ng== +"@lerna/pack-directory@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/pack-directory/-/pack-directory-4.0.0.tgz#8b617db95d20792f043aaaa13a9ccc0e04cb4c74" + integrity sha512-NJrmZNmBHS+5aM+T8N6FVbaKFScVqKlQFJNY2k7nsJ/uklNKsLLl6VhTQBPwMTbf6Tf7l6bcKzpy7aePuq9UiQ== dependencies: - "@lerna/get-packed" "3.16.0" - "@lerna/package" "3.16.0" - "@lerna/run-lifecycle" "3.16.2" - figgy-pudding "^3.5.1" - npm-packlist "^1.4.4" + "@lerna/get-packed" "4.0.0" + "@lerna/package" "4.0.0" + "@lerna/run-lifecycle" "4.0.0" + npm-packlist "^2.1.4" npmlog "^4.1.2" - tar "^4.4.10" - temp-write "^3.4.0" + tar "^6.1.0" + temp-write "^4.0.0" -"@lerna/package-graph@3.18.5", "@lerna/package-graph@^3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/package-graph/-/package-graph-3.18.5.tgz#c740e2ea3578d059e551633e950690831b941f6b" - integrity sha512-8QDrR9T+dBegjeLr+n9WZTVxUYUhIUjUgZ0gvNxUBN8S1WB9r6H5Yk56/MVaB64tA3oGAN9IIxX6w0WvTfFudA== +"@lerna/package-graph@4.0.0", "@lerna/package-graph@^4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/package-graph/-/package-graph-4.0.0.tgz#16a00253a8ac810f72041481cb46bcee8d8123dd" + integrity sha512-QED2ZCTkfXMKFoTGoccwUzjHtZMSf3UKX14A4/kYyBms9xfFsesCZ6SLI5YeySEgcul8iuIWfQFZqRw+Qrjraw== dependencies: - "@lerna/prerelease-id-from-version" "3.16.0" - "@lerna/validation-error" "3.13.0" - npm-package-arg "^6.1.0" + "@lerna/prerelease-id-from-version" "4.0.0" + "@lerna/validation-error" "4.0.0" + npm-package-arg "^8.1.0" npmlog "^4.1.2" - semver "^6.2.0" + semver "^7.3.4" -"@lerna/package@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/package/-/package-3.16.0.tgz#7e0a46e4697ed8b8a9c14d59c7f890e0d38ba13c" - integrity sha512-2lHBWpaxcBoiNVbtyLtPUuTYEaB/Z+eEqRS9duxpZs6D+mTTZMNy6/5vpEVSCBmzvdYpyqhqaYjjSLvjjr5Riw== +"@lerna/package@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/package/-/package-4.0.0.tgz#1b4c259c4bcff45c876ee1d591a043aacbc0d6b7" + integrity sha512-l0M/izok6FlyyitxiQKr+gZLVFnvxRQdNhzmQ6nRnN9dvBJWn+IxxpM+cLqGACatTnyo9LDzNTOj2Db3+s0s8Q== dependencies: - load-json-file "^5.3.0" - npm-package-arg "^6.1.0" - write-pkg "^3.1.0" + load-json-file "^6.2.0" + npm-package-arg "^8.1.0" + write-pkg "^4.0.0" -"@lerna/prerelease-id-from-version@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/prerelease-id-from-version/-/prerelease-id-from-version-3.16.0.tgz#b24bfa789f5e1baab914d7b08baae9b7bd7d83a1" - integrity sha512-qZyeUyrE59uOK8rKdGn7jQz+9uOpAaF/3hbslJVFL1NqF9ELDTqjCPXivuejMX/lN4OgD6BugTO4cR7UTq/sZA== +"@lerna/prerelease-id-from-version@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/prerelease-id-from-version/-/prerelease-id-from-version-4.0.0.tgz#c7e0676fcee1950d85630e108eddecdd5b48c916" + integrity sha512-GQqguzETdsYRxOSmdFZ6zDBXDErIETWOqomLERRY54f4p+tk4aJjoVdd9xKwehC9TBfIFvlRbL1V9uQGHh1opg== dependencies: - semver "^6.2.0" + semver "^7.3.4" -"@lerna/profiler@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/profiler/-/profiler-3.20.0.tgz#0f6dc236f4ea8f9ea5f358c6703305a4f32ad051" - integrity sha512-bh8hKxAlm6yu8WEOvbLENm42i2v9SsR4WbrCWSbsmOElx3foRnMlYk7NkGECa+U5c3K4C6GeBbwgqs54PP7Ljg== +"@lerna/profiler@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/profiler/-/profiler-4.0.0.tgz#8a53ab874522eae15d178402bff90a14071908e9" + integrity sha512-/BaEbqnVh1LgW/+qz8wCuI+obzi5/vRE8nlhjPzdEzdmWmZXuCKyWSEzAyHOJWw1ntwMiww5dZHhFQABuoFz9Q== dependencies: - figgy-pudding "^3.5.1" - fs-extra "^8.1.0" + fs-extra "^9.1.0" npmlog "^4.1.2" - upath "^1.2.0" + upath "^2.0.1" -"@lerna/project@3.21.0", "@lerna/project@^3.18.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/project/-/project-3.21.0.tgz#5d784d2d10c561a00f20320bcdb040997c10502d" - integrity sha512-xT1mrpET2BF11CY32uypV2GPtPVm6Hgtha7D81GQP9iAitk9EccrdNjYGt5UBYASl4CIDXBRxwmTTVGfrCx82A== +"@lerna/project@4.0.0", "@lerna/project@^4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/project/-/project-4.0.0.tgz#ff84893935833533a74deff30c0e64ddb7f0ba6b" + integrity sha512-o0MlVbDkD5qRPkFKlBZsXZjoNTWPyuL58564nSfZJ6JYNmgAptnWPB2dQlAc7HWRZkmnC2fCkEdoU+jioPavbg== dependencies: - "@lerna/package" "3.16.0" - "@lerna/validation-error" "3.13.0" - cosmiconfig "^5.1.0" + "@lerna/package" "4.0.0" + "@lerna/validation-error" "4.0.0" + cosmiconfig "^7.0.0" dedent "^0.7.0" - dot-prop "^4.2.0" - glob-parent "^5.0.0" - globby "^9.2.0" - load-json-file "^5.3.0" + dot-prop "^6.0.1" + glob-parent "^5.1.1" + globby "^11.0.2" + load-json-file "^6.2.0" npmlog "^4.1.2" - p-map "^2.1.0" - resolve-from "^4.0.0" - write-json-file "^3.2.0" + p-map "^4.0.0" + resolve-from "^5.0.0" + write-json-file "^4.3.0" -"@lerna/prompt@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/prompt/-/prompt-3.18.5.tgz#628cd545f225887d060491ab95df899cfc5218a1" - integrity sha512-rkKj4nm1twSbBEb69+Em/2jAERK8htUuV8/xSjN0NPC+6UjzAwY52/x9n5cfmpa9lyKf/uItp7chCI7eDmNTKQ== +"@lerna/prompt@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/prompt/-/prompt-4.0.0.tgz#5ec69a803f3f0db0ad9f221dad64664d3daca41b" + integrity sha512-4Ig46oCH1TH5M7YyTt53fT6TuaKMgqUUaqdgxvp6HP6jtdak6+amcsqB8YGz2eQnw/sdxunx84DfI9XpoLj4bQ== dependencies: - inquirer "^6.2.0" + inquirer "^7.3.3" npmlog "^4.1.2" -"@lerna/publish@3.22.1": - version "3.22.1" - resolved "https://registry.npmjs.org/@lerna/publish/-/publish-3.22.1.tgz#b4f7ce3fba1e9afb28be4a1f3d88222269ba9519" - integrity sha512-PG9CM9HUYDreb1FbJwFg90TCBQooGjj+n/pb3gw/eH5mEDq0p8wKdLFe0qkiqUkm/Ub5C8DbVFertIo0Vd0zcw== +"@lerna/publish@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/publish/-/publish-4.0.0.tgz#f67011305adeba120066a3b6d984a5bb5fceef65" + integrity sha512-K8jpqjHrChH22qtkytA5GRKIVFEtqBF6JWj1I8dWZtHs4Jywn8yB1jQ3BAMLhqmDJjWJtRck0KXhQQKzDK2UPg== dependencies: - "@evocateur/libnpmaccess" "^3.1.2" - "@evocateur/npm-registry-fetch" "^4.0.0" - "@evocateur/pacote" "^9.6.3" - "@lerna/check-working-tree" "3.16.5" - "@lerna/child-process" "3.16.5" - "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.21.0" - "@lerna/describe-ref" "3.16.5" - "@lerna/log-packed" "3.16.0" - "@lerna/npm-conf" "3.16.0" - "@lerna/npm-dist-tag" "3.18.5" - "@lerna/npm-publish" "3.18.5" - "@lerna/otplease" "3.18.5" - "@lerna/output" "3.13.0" - "@lerna/pack-directory" "3.16.4" - "@lerna/prerelease-id-from-version" "3.16.0" - "@lerna/prompt" "3.18.5" - "@lerna/pulse-till-done" "3.13.0" - "@lerna/run-lifecycle" "3.16.2" - "@lerna/run-topologically" "3.18.5" - "@lerna/validation-error" "3.13.0" - "@lerna/version" "3.22.1" - figgy-pudding "^3.5.1" - fs-extra "^8.1.0" - npm-package-arg "^6.1.0" + "@lerna/check-working-tree" "4.0.0" + "@lerna/child-process" "4.0.0" + "@lerna/collect-updates" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/describe-ref" "4.0.0" + "@lerna/log-packed" "4.0.0" + "@lerna/npm-conf" "4.0.0" + "@lerna/npm-dist-tag" "4.0.0" + "@lerna/npm-publish" "4.0.0" + "@lerna/otplease" "4.0.0" + "@lerna/output" "4.0.0" + "@lerna/pack-directory" "4.0.0" + "@lerna/prerelease-id-from-version" "4.0.0" + "@lerna/prompt" "4.0.0" + "@lerna/pulse-till-done" "4.0.0" + "@lerna/run-lifecycle" "4.0.0" + "@lerna/run-topologically" "4.0.0" + "@lerna/validation-error" "4.0.0" + "@lerna/version" "4.0.0" + fs-extra "^9.1.0" + libnpmaccess "^4.0.1" + npm-package-arg "^8.1.0" + npm-registry-fetch "^9.0.0" npmlog "^4.1.2" - p-finally "^1.0.0" - p-map "^2.1.0" - p-pipe "^1.2.0" - semver "^6.2.0" + p-map "^4.0.0" + p-pipe "^3.1.0" + pacote "^11.2.6" + semver "^7.3.4" -"@lerna/pulse-till-done@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/pulse-till-done/-/pulse-till-done-3.13.0.tgz#c8e9ce5bafaf10d930a67d7ed0ccb5d958fe0110" - integrity sha512-1SOHpy7ZNTPulzIbargrgaJX387csN7cF1cLOGZiJQA6VqnS5eWs2CIrG8i8wmaUavj2QlQ5oEbRMVVXSsGrzA== +"@lerna/pulse-till-done@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/pulse-till-done/-/pulse-till-done-4.0.0.tgz#04bace7d483a8205c187b806bcd8be23d7bb80a3" + integrity sha512-Frb4F7QGckaybRhbF7aosLsJ5e9WuH7h0KUkjlzSByVycxY91UZgaEIVjS2oN9wQLrheLMHl6SiFY0/Pvo0Cxg== dependencies: npmlog "^4.1.2" -"@lerna/query-graph@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/query-graph/-/query-graph-3.18.5.tgz#df4830bb5155273003bf35e8dda1c32d0927bd86" - integrity sha512-50Lf4uuMpMWvJ306be3oQDHrWV42nai9gbIVByPBYJuVW8dT8O8pA3EzitNYBUdLL9/qEVbrR0ry1HD7EXwtRA== +"@lerna/query-graph@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/query-graph/-/query-graph-4.0.0.tgz#09dd1c819ac5ee3f38db23931143701f8a6eef63" + integrity sha512-YlP6yI3tM4WbBmL9GCmNDoeQyzcyg1e4W96y/PKMZa5GbyUvkS2+Jc2kwPD+5KcXou3wQZxSPzR3Te5OenaDdg== dependencies: - "@lerna/package-graph" "3.18.5" - figgy-pudding "^3.5.1" + "@lerna/package-graph" "4.0.0" -"@lerna/resolve-symlink@3.16.0": - version "3.16.0" - resolved "https://registry.npmjs.org/@lerna/resolve-symlink/-/resolve-symlink-3.16.0.tgz#37fc7095fabdbcf317c26eb74e0d0bde8efd2386" - integrity sha512-Ibj5e7njVHNJ/NOqT4HlEgPFPtPLWsO7iu59AM5bJDcAJcR96mLZ7KGVIsS2tvaO7akMEJvt2P+ErwCdloG3jQ== +"@lerna/resolve-symlink@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/resolve-symlink/-/resolve-symlink-4.0.0.tgz#6d006628a210c9b821964657a9e20a8c9a115e14" + integrity sha512-RtX8VEUzqT+uLSCohx8zgmjc6zjyRlh6i/helxtZTMmc4+6O4FS9q5LJas2uGO2wKvBlhcD6siibGt7dIC3xZA== dependencies: - fs-extra "^8.1.0" + fs-extra "^9.1.0" npmlog "^4.1.2" - read-cmd-shim "^1.0.1" + read-cmd-shim "^2.0.0" -"@lerna/rimraf-dir@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/rimraf-dir/-/rimraf-dir-3.16.5.tgz#04316ab5ffd2909657aaf388ea502cb8c2f20a09" - integrity sha512-bQlKmO0pXUsXoF8lOLknhyQjOZsCc0bosQDoX4lujBXSWxHVTg1VxURtWf2lUjz/ACsJVDfvHZbDm8kyBk5okA== +"@lerna/rimraf-dir@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/rimraf-dir/-/rimraf-dir-4.0.0.tgz#2edf3b62d4eb0ef4e44e430f5844667d551ec25a" + integrity sha512-QNH9ABWk9mcMJh2/muD9iYWBk1oQd40y6oH+f3wwmVGKYU5YJD//+zMiBI13jxZRtwBx0vmBZzkBkK1dR11cBg== dependencies: - "@lerna/child-process" "3.16.5" + "@lerna/child-process" "4.0.0" npmlog "^4.1.2" - path-exists "^3.0.0" - rimraf "^2.6.2" + path-exists "^4.0.0" + rimraf "^3.0.2" -"@lerna/run-lifecycle@3.16.2": - version "3.16.2" - resolved "https://registry.npmjs.org/@lerna/run-lifecycle/-/run-lifecycle-3.16.2.tgz#67b288f8ea964db9ea4fb1fbc7715d5bbb0bce00" - integrity sha512-RqFoznE8rDpyyF0rOJy3+KjZCeTkO8y/OB9orPauR7G2xQ7PTdCpgo7EO6ZNdz3Al+k1BydClZz/j78gNCmL2A== +"@lerna/run-lifecycle@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/run-lifecycle/-/run-lifecycle-4.0.0.tgz#e648a46f9210a9bcd7c391df6844498cb5079334" + integrity sha512-IwxxsajjCQQEJAeAaxF8QdEixfI7eLKNm4GHhXHrgBu185JcwScFZrj9Bs+PFKxwb+gNLR4iI5rpUdY8Y0UdGQ== dependencies: - "@lerna/npm-conf" "3.16.0" - figgy-pudding "^3.5.1" - npm-lifecycle "^3.1.2" + "@lerna/npm-conf" "4.0.0" + npm-lifecycle "^3.1.5" npmlog "^4.1.2" -"@lerna/run-topologically@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/run-topologically/-/run-topologically-3.18.5.tgz#3cd639da20e967d7672cb88db0f756b92f2fdfc3" - integrity sha512-6N1I+6wf4hLOnPW+XDZqwufyIQ6gqoPfHZFkfWlvTQ+Ue7CuF8qIVQ1Eddw5HKQMkxqN10thKOFfq/9NQZ4NUg== +"@lerna/run-topologically@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/run-topologically/-/run-topologically-4.0.0.tgz#af846eeee1a09b0c2be0d1bfb5ef0f7b04bb1827" + integrity sha512-EVZw9hGwo+5yp+VL94+NXRYisqgAlj0jWKWtAIynDCpghRxCE5GMO3xrQLmQgqkpUl9ZxQFpICgYv5DW4DksQA== dependencies: - "@lerna/query-graph" "3.18.5" - figgy-pudding "^3.5.1" - p-queue "^4.0.0" + "@lerna/query-graph" "4.0.0" + p-queue "^6.6.2" -"@lerna/run@3.21.0": - version "3.21.0" - resolved "https://registry.npmjs.org/@lerna/run/-/run-3.21.0.tgz#2a35ec84979e4d6e42474fe148d32e5de1cac891" - integrity sha512-fJF68rT3veh+hkToFsBmUJ9MHc9yGXA7LSDvhziAojzOb0AI/jBDp6cEcDQyJ7dbnplba2Lj02IH61QUf9oW0Q== +"@lerna/run@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/run/-/run-4.0.0.tgz#4bc7fda055a729487897c23579694f6183c91262" + integrity sha512-9giulCOzlMPzcZS/6Eov6pxE9gNTyaXk0Man+iCIdGJNMrCnW7Dme0Z229WWP/UoxDKg71F2tMsVVGDiRd8fFQ== dependencies: - "@lerna/command" "3.21.0" - "@lerna/filter-options" "3.20.0" - "@lerna/npm-run-script" "3.16.5" - "@lerna/output" "3.13.0" - "@lerna/profiler" "3.20.0" - "@lerna/run-topologically" "3.18.5" - "@lerna/timer" "3.13.0" - "@lerna/validation-error" "3.13.0" - p-map "^2.1.0" + "@lerna/command" "4.0.0" + "@lerna/filter-options" "4.0.0" + "@lerna/npm-run-script" "4.0.0" + "@lerna/output" "4.0.0" + "@lerna/profiler" "4.0.0" + "@lerna/run-topologically" "4.0.0" + "@lerna/timer" "4.0.0" + "@lerna/validation-error" "4.0.0" + p-map "^4.0.0" -"@lerna/symlink-binary@3.17.0": - version "3.17.0" - resolved "https://registry.npmjs.org/@lerna/symlink-binary/-/symlink-binary-3.17.0.tgz#8f8031b309863814883d3f009877f82e38aef45a" - integrity sha512-RLpy9UY6+3nT5J+5jkM5MZyMmjNHxZIZvXLV+Q3MXrf7Eaa1hNqyynyj4RO95fxbS+EZc4XVSk25DGFQbcRNSQ== +"@lerna/symlink-binary@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/symlink-binary/-/symlink-binary-4.0.0.tgz#21009f62d53a425f136cb4c1a32c6b2a0cc02d47" + integrity sha512-zualodWC4q1QQc1pkz969hcFeWXOsVYZC5AWVtAPTDfLl+TwM7eG/O6oP+Rr3fFowspxo6b1TQ6sYfDV6HXNWA== dependencies: - "@lerna/create-symlink" "3.16.2" - "@lerna/package" "3.16.0" - fs-extra "^8.1.0" - p-map "^2.1.0" + "@lerna/create-symlink" "4.0.0" + "@lerna/package" "4.0.0" + fs-extra "^9.1.0" + p-map "^4.0.0" -"@lerna/symlink-dependencies@3.17.0": - version "3.17.0" - resolved "https://registry.npmjs.org/@lerna/symlink-dependencies/-/symlink-dependencies-3.17.0.tgz#48d6360e985865a0e56cd8b51b308a526308784a" - integrity sha512-KmjU5YT1bpt6coOmdFueTJ7DFJL4H1w5eF8yAQ2zsGNTtZ+i5SGFBWpb9AQaw168dydc3s4eu0W0Sirda+F59Q== +"@lerna/symlink-dependencies@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/symlink-dependencies/-/symlink-dependencies-4.0.0.tgz#8910eca084ae062642d0490d8972cf2d98e9ebbd" + integrity sha512-BABo0MjeUHNAe2FNGty1eantWp8u83BHSeIMPDxNq0MuW2K3CiQRaeWT3EGPAzXpGt0+hVzBrA6+OT0GPn7Yuw== dependencies: - "@lerna/create-symlink" "3.16.2" - "@lerna/resolve-symlink" "3.16.0" - "@lerna/symlink-binary" "3.17.0" - fs-extra "^8.1.0" - p-finally "^1.0.0" - p-map "^2.1.0" - p-map-series "^1.0.0" + "@lerna/create-symlink" "4.0.0" + "@lerna/resolve-symlink" "4.0.0" + "@lerna/symlink-binary" "4.0.0" + fs-extra "^9.1.0" + p-map "^4.0.0" + p-map-series "^2.1.0" -"@lerna/timer@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/timer/-/timer-3.13.0.tgz#bcd0904551db16e08364d6c18e5e2160fc870781" - integrity sha512-RHWrDl8U4XNPqY5MQHkToWS9jHPnkLZEt5VD+uunCKTfzlxGnRCr3/zVr8VGy/uENMYpVP3wJa4RKGY6M0vkRw== +"@lerna/timer@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/timer/-/timer-4.0.0.tgz#a52e51bfcd39bfd768988049ace7b15c1fd7a6da" + integrity sha512-WFsnlaE7SdOvjuyd05oKt8Leg3ENHICnvX3uYKKdByA+S3g+TCz38JsNs7OUZVt+ba63nC2nbXDlUnuT2Xbsfg== -"@lerna/validation-error@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/validation-error/-/validation-error-3.13.0.tgz#c86b8f07c5ab9539f775bd8a54976e926f3759c3" - integrity sha512-SiJP75nwB8GhgwLKQfdkSnDufAaCbkZWJqEDlKOUPUvVOplRGnfL+BPQZH5nvq2BYSRXsksXWZ4UHVnQZI/HYA== +"@lerna/validation-error@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/validation-error/-/validation-error-4.0.0.tgz#af9d62fe8304eaa2eb9a6ba1394f9aa807026d35" + integrity sha512-1rBOM5/koiVWlRi3V6dB863E1YzJS8v41UtsHgMr6gB2ncJ2LsQtMKlJpi3voqcgh41H8UsPXR58RrrpPpufyw== dependencies: npmlog "^4.1.2" -"@lerna/version@3.22.1": - version "3.22.1" - resolved "https://registry.npmjs.org/@lerna/version/-/version-3.22.1.tgz#9805a9247a47ee62d6b81bd9fa5fb728b24b59e2" - integrity sha512-PSGt/K1hVqreAFoi3zjD0VEDupQ2WZVlVIwesrE5GbrL2BjXowjCsTDPqblahDUPy0hp6h7E2kG855yLTp62+g== +"@lerna/version@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/version/-/version-4.0.0.tgz#532659ec6154d8a8789c5ab53878663e244e3228" + integrity sha512-otUgiqs5W9zGWJZSCCMRV/2Zm2A9q9JwSDS7s/tlKq4mWCYriWo7+wsHEA/nPTMDyYyBO5oyZDj+3X50KDUzeA== dependencies: - "@lerna/check-working-tree" "3.16.5" - "@lerna/child-process" "3.16.5" - "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.21.0" - "@lerna/conventional-commits" "3.22.0" - "@lerna/github-client" "3.22.0" - "@lerna/gitlab-client" "3.15.0" - "@lerna/output" "3.13.0" - "@lerna/prerelease-id-from-version" "3.16.0" - "@lerna/prompt" "3.18.5" - "@lerna/run-lifecycle" "3.16.2" - "@lerna/run-topologically" "3.18.5" - "@lerna/validation-error" "3.13.0" - chalk "^2.3.1" + "@lerna/check-working-tree" "4.0.0" + "@lerna/child-process" "4.0.0" + "@lerna/collect-updates" "4.0.0" + "@lerna/command" "4.0.0" + "@lerna/conventional-commits" "4.0.0" + "@lerna/github-client" "4.0.0" + "@lerna/gitlab-client" "4.0.0" + "@lerna/output" "4.0.0" + "@lerna/prerelease-id-from-version" "4.0.0" + "@lerna/prompt" "4.0.0" + "@lerna/run-lifecycle" "4.0.0" + "@lerna/run-topologically" "4.0.0" + "@lerna/validation-error" "4.0.0" + chalk "^4.1.0" dedent "^0.7.0" - load-json-file "^5.3.0" + load-json-file "^6.2.0" minimatch "^3.0.4" npmlog "^4.1.2" - p-map "^2.1.0" - p-pipe "^1.2.0" - p-reduce "^1.0.0" - p-waterfall "^1.0.0" - semver "^6.2.0" - slash "^2.0.0" - temp-write "^3.4.0" - write-json-file "^3.2.0" + p-map "^4.0.0" + p-pipe "^3.1.0" + p-reduce "^2.1.0" + p-waterfall "^2.1.1" + semver "^7.3.4" + slash "^3.0.0" + temp-write "^4.0.0" + write-json-file "^4.3.0" -"@lerna/write-log-file@3.13.0": - version "3.13.0" - resolved "https://registry.npmjs.org/@lerna/write-log-file/-/write-log-file-3.13.0.tgz#b78d9e4cfc1349a8be64d91324c4c8199e822a26" - integrity sha512-RibeMnDPvlL8bFYW5C8cs4mbI3AHfQef73tnJCQ/SgrXZHehmHnsyWUiE7qDQCAo+B1RfTapvSyFF69iPj326A== +"@lerna/write-log-file@4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@lerna/write-log-file/-/write-log-file-4.0.0.tgz#18221a38a6a307d6b0a5844dd592ad53fa27091e" + integrity sha512-XRG5BloiArpXRakcnPHmEHJp+4AtnhRtpDIHSghmXD5EichI1uD73J7FgPp30mm2pDRq3FdqB0NbwSEsJ9xFQg== dependencies: npmlog "^4.1.2" - write-file-atomic "^2.3.0" + write-file-atomic "^3.0.3" "@manypkg/find-root@^1.1.0": version "1.1.0" @@ -3304,9 +3878,9 @@ react-transition-group "^4.4.0" "@material-ui/icons@^4.9.1": - version "4.9.1" - resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.9.1.tgz#fdeadf8cb3d89208945b33dbc50c7c616d0bd665" - integrity sha512-GBitL3oBWO0hzBhvA9KxqcowRUsA0qzwKkURyC8nppnC3fw54KPKZ+d4V1Eeg/UnDRSzDaI9nGCdel/eh9AQMg== + version "4.11.2" + resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" + integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== dependencies: "@babel/runtime" "^7.4.4" @@ -3321,17 +3895,6 @@ prop-types "^15.7.2" react-is "^16.8.0" -"@material-ui/lab@^4.0.0-alpha.56": - version "4.0.0-alpha.56" - resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.56.tgz#ff63080949b55b40625e056bbda05e130d216d34" - integrity sha512-xPlkK+z/6y/24ka4gVJgwPfoCF4RCh8dXb1BNE7MtF9bXEBLN/lBxNTK8VAa0qm3V2oinA6xtUIdcRh0aeRtVw== - dependencies: - "@babel/runtime" "^7.4.4" - "@material-ui/utils" "^4.10.2" - clsx "^1.0.4" - prop-types "^15.7.2" - react-is "^16.8.0" - "@material-ui/pickers@^3.2.2": version "3.2.10" resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz#19df024895876eb0ec7cd239bbaea595f703f0ae" @@ -3397,11 +3960,6 @@ dependencies: "@types/whatwg-streams" "^0.0.7" -"@mdx-js/react@^1.0.0", "@mdx-js/react@^1.5.2": - version "1.5.9" - resolved "https://registry.npmjs.org/@mdx-js/react/-/react-1.5.9.tgz#31873ab097fbe58c61c7585fc0be64e83182b6df" - integrity sha512-rengdUSedIdIQbXPSeafItCacTYocARAjUA51b6R1KNHmz+59efz7UmyTKr73viJQZ98ouu7iRGmOTtjRrbbWA== - "@microsoft/microsoft-graph-types@^1.25.0": version "1.25.0" resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-1.25.0.tgz#1f543ebc029a115dd1d48a1ae99d7ddd5ee9af57" @@ -3415,6 +3973,11 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@n1ru4l/push-pull-async-iterable-iterator@^2.0.1": + version "2.1.2" + resolved "https://registry.npmjs.org/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-2.1.2.tgz#e486bf86c4c29e78601694a26f31c2dec0c08d9b" + integrity sha512-KwZGeX2XK7Xj9ksWwei5923QnqIGoEuLlh3O46OW9vc8hQxjzmMTKCgJMVZ5ne5xaWFQYDT2dMpbUhq6hEOhxA== + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" @@ -3441,6 +4004,34 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@npmcli/ci-detect@^1.0.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz#6c1d2c625fb6ef1b9dea85ad0a5afcbef85ef22a" + integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q== + +"@npmcli/git@^2.0.1": + version "2.0.6" + resolved "https://registry.npmjs.org/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2" + integrity sha512-a1MnTfeRPBaKbFY07fd+6HugY1WAkKJzdiJvlRub/9o5xz2F/JtPacZZapx5zRJUQFIzSL677vmTSxEcDMrDbg== + dependencies: + "@npmcli/promise-spawn" "^1.1.0" + lru-cache "^6.0.0" + mkdirp "^1.0.3" + npm-pick-manifest "^6.0.0" + promise-inflight "^1.0.1" + promise-retry "^2.0.1" + semver "^7.3.2" + unique-filename "^1.1.1" + which "^2.0.2" + +"@npmcli/installed-package-contents@^1.0.6": + version "1.0.7" + resolved "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-1.0.7.tgz#ab7408c6147911b970a8abe261ce512232a3f4fa" + integrity sha512-9rufe0wnJusCQoLpV9ZPKIVP55itrM5BxOXs10DmdbRfgWtHy1LDyskbwRnBghuB0PrF7pNPOqREVtpz4HqzKw== + dependencies: + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + "@npmcli/move-file@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" @@ -3448,24 +4039,62 @@ 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== - dependencies: - "@octokit/types" "^2.0.0" +"@npmcli/node-gyp@^1.0.2": + version "1.0.2" + resolved "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-1.0.2.tgz#3cdc1f30e9736dbc417373ed803b42b1a0a29ede" + integrity sha512-yrJUe6reVMpktcvagumoqD9r08fH1iRo01gn1u0zoCApa9lnZGEigVKUd2hzsCId4gdtkZZIVscLhNxMECKgRg== -"@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== +"@npmcli/promise-spawn@^1.1.0", "@npmcli/promise-spawn@^1.2.0", "@npmcli/promise-spawn@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-1.3.2.tgz#42d4e56a8e9274fba180dabc0aea6e38f29274f5" + integrity sha512-QyAGYo/Fbj4MXeGdJcFzZ+FkDkomfRBrPM+9QYJSg+PxgAUL+LU3FneQk37rKR2/zjqkCV1BLHccX98wRXG3Sg== dependencies: - "@octokit/auth-token" "^2.4.0" - "@octokit/graphql" "^4.3.1" - "@octokit/request" "^5.4.0" - "@octokit/types" "^5.0.0" + infer-owner "^1.0.4" + +"@npmcli/run-script@^1.8.2": + version "1.8.3" + resolved "https://registry.npmjs.org/@npmcli/run-script/-/run-script-1.8.3.tgz#07f440ed492400bb1114369bc37315eeaaae2bb3" + integrity sha512-ELPGWAVU/xyU+A+H3pEPj0QOvYwLTX71RArXcClFzeiyJ/b/McsZ+d0QxpznvfFtZzxGN/gz/1cvlqICR4/suQ== + dependencies: + "@npmcli/node-gyp" "^1.0.2" + "@npmcli/promise-spawn" "^1.3.2" + infer-owner "^1.0.4" + node-gyp "^7.1.0" + puka "^1.0.1" + read-package-json-fast "^2.0.1" + +"@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/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/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/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" @@ -3476,81 +4105,50 @@ 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.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/openapi-types@^4.0.3": + version "4.0.4" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-4.0.4.tgz#96fcce11e929802898646205ac567e5df592f82b" + integrity sha512-31zY8JIuz3h6RAFOnyA8FbOwhILILiBu1qD81RyZZWY7oMBhIdBn6MaAmnnptLhB4jk0g50nkQkUVP4kUzppcA== + "@octokit/plugin-enterprise-rest@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-6.0.1.tgz#e07896739618dab8da7d4077c658003775f95437" integrity sha512-93uGjlhUD+iNg1iWhUENAtJata6w5nE+V4urXOAlIXdco6xNZtUSfYY8dzp3Udy74aqO/B5UZL80x/YMa5PKRw== -"@octokit/plugin-paginate-rest@^1.1.1": - version "1.1.2" - resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz#004170acf8c2be535aba26727867d692f7b488fc" - integrity sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q== +"@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" "^2.0.1" + "@octokit/types" "^6.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-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@4.10.3": + version "4.10.3" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.10.3.tgz#d78ddf926bca3b81a4d9b79a463d32b3750a4485" + integrity sha512-CsNQeVY34Vs9iea2Z9/TCPlebxv6KpjO9f1BUPz+14qundTSYT9kgf8j5wA1k37VstfBQ4xnuURYdnbGzJBJXw== dependencies: - "@octokit/types" "^5.0.0" - -"@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-rest-endpoint-methods@2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz#3288ecf5481f68c494dd0602fc15407a59faf61e" - integrity sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ== - dependencies: - "@octokit/types" "^2.0.1" + "@octokit/types" "^6.8.3" 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== - 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" - deprecation "^2.3.1" - -"@octokit/request-error@^1.0.2": - version "1.2.1" - resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801" - integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA== - dependencies: - "@octokit/types" "^2.0.0" - deprecation "^2.0.0" - once "^1.4.0" - "@octokit/request-error@^2.0.0": version "2.0.2" resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.2.tgz#0e76b83f5d8fdda1db99027ea5f617c2e6ba9ed0" @@ -3560,90 +4158,44 @@ 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.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" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.1.tgz#3b11e7d1b1ac2bbeeb23b08a17df0b20947eda6b" - integrity sha512-gfFKwRT/wFxq5qlNjnW2dh+qh74XgTQ2B179UX5K1HYCluioWj8Ndbgqw2PVqa1NnVJkGHp2ovMpVn/DImlmkw== +"@octokit/rest@^18.0.0", "@octokit/rest@^18.0.12", "@octokit/rest@^18.1.0": + version "18.1.1" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.1.1.tgz#bd7053c28db3577c936029e9da6bfbd046474a2f" + integrity sha512-ZcCHMyfGT1qtJD72usigAfUQ6jU89ZUPFb2AOubR6WZ7/RRFVZUENVm1I2yvJBUicqTujezPW9cY1+o3Mb4rNA== dependencies: - "@octokit/auth-token" "^2.4.0" - "@octokit/plugin-paginate-rest" "^1.1.1" - "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "2.4.0" - "@octokit/request" "^5.2.0" - "@octokit/request-error" "^1.0.2" - atob-lite "^2.0.0" - before-after-hook "^2.0.0" - btoa-lite "^1.0.0" - deprecation "^2.0.0" - lodash.get "^4.4.2" - lodash.set "^4.3.2" - lodash.uniq "^4.5.0" - octokit-pagination-methods "^1.1.0" - 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== - 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/types@^2.0.0", "@octokit/types@^2.0.1": - version "2.5.0" - resolved "https://registry.npmjs.org/@octokit/types/-/types-2.5.0.tgz#f1bbd147e662ae2c79717d518aac686e58257773" - integrity sha512-KEnLwOfdXzxPNL34fj508bhi9Z9cStyN7qY1kOfVahmqtAfrWw6Oq3P4R+dtsg0lYtZdWBpUrS/Ixmd5YILSww== - dependencies: - "@types/node" ">= 8" + "@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.10.3" "@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.8.3": + version "6.8.5" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.8.5.tgz#797dfdad8c75718e97dc687d4c9fc49200ca8d17" + integrity sha512-ZsQawftZoi0kSF2pCsdgLURbOjtVcHnBOXiSxBKSNF56CRjARt5rb/g8WJgqB8vv4lgUEHrv06EdDKYQ22vA9Q== + dependencies: + "@octokit/openapi-types" "^4.0.3" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -3657,11 +4209,45 @@ 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" integrity sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw== +"@pmmmwh/react-refresh-webpack-plugin@^0.4.2": + version "0.4.3" + resolved "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz#1eec460596d200c0236bf195b078a5d1df89b766" + integrity sha512-br5Qwvh8D2OQqSXpd1g/xqXKnK0r+Jz6qVKBbWmpUcrbGOxUrf39V5oZ1876084CGn18uMdR5uvPqBv9UqtBjQ== + dependencies: + ansi-html "^0.0.7" + error-stack-parser "^2.0.6" + html-entities "^1.2.1" + native-url "^0.2.6" + schema-utils "^2.6.5" + source-map "^0.7.3" + +"@popperjs/core@^2.5.4": + version "2.5.4" + resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a" + integrity sha512-ZpKr+WTb8zsajqgDkvCEWgp6d5eJT6Q63Ng2neTbzBO76Lbe91vX/iVIW9dikq+Fs3yEo+ls4cxeXABD2LtcbQ== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -3743,9 +4329,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" @@ -3785,27 +4371,27 @@ react-router "^6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-github-pull-requests@^0.6.3": - version "0.6.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.6.3.tgz#46b63e90f3f5412a4b8f0df96ada62cff7046478" - integrity sha512-ofWH9k4WVVwTbK/XAVqmtH03QW3rsT822p4neOse0Wy0dAKlqlSU4nwl5jBKMJXsf8lfc0c7gbc50R7VumzoOQ== +"@roadiehq/backstage-plugin-github-pull-requests@^0.7.6": + version "0.7.6" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.7.6.tgz#06a7c22ddf26ab504dd8f95c96ef0319c915ed80" + integrity sha512-UMNniDR+0MGhVX6f4Bojm4BOmv6u323ycWkzXNLoQpWSm03EpZGeT8FL1Q0CY0Cd4DgdoV+L0seFqt8jEbOr0g== dependencies: - "@backstage/catalog-model" "^0.2.0" - "@backstage/core" "^0.3.0" - "@backstage/plugin-catalog" "^0.2.0" - "@backstage/theme" "^0.2.0" + "@backstage/catalog-model" "^0.7.1" + "@backstage/core" "^0.6.1" + "@backstage/plugin-catalog" "^0.3.1" + "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "^4.0.0-alpha.56" "@octokit/rest" "^18.0.0" "@octokit/types" "^5.0.1" - "@types/react-dom" "^16.9.8" + "@types/node-fetch" "^2.5.7" history "^5.0.0" moment "^2.27.0" + node-fetch "^2.6.1" react "^16.13.1" react-dom "^16.13.1" react-router "6.0.0-beta.0" - react-use "^15.3.3" + react-use "^15.3.6" "@roadiehq/backstage-plugin-travis-ci@^0.2.8": version "0.2.8" @@ -3830,10 +4416,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" @@ -3900,11 +4486,6 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== -"@sindresorhus/is@^2.0.0": - version "2.1.1" - resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz#ceff6a28a5b4867c2dd4a1ba513de278ccbe8bb1" - integrity sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg== - "@sindresorhus/is@^3.1.1": version "3.1.2" resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.2.tgz#548650de521b344e3781fbdb0ece4aa6f729afb8" @@ -3951,270 +4532,383 @@ resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-9.0.0.tgz#be68cfaf212599f0bfeb6536c7c58ec05d2b6fba" integrity sha512-ZsXTwMA68ZCz943U4N8XwprdWcc7ErOO/IW8PewLK5lycCZtLnmRkOvAbae7O5qNJPD8b/l0iUMTLaZuwjXWwg== -"@spotify/prettier-config@^8.0.0": - version "8.0.0" - resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-8.0.0.tgz#8b6c2bd579ddc54887155a0721fe04e96c89f7f2" - integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ== +"@spotify/prettier-config@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-9.0.0.tgz#7b562d56573c6fc0094446fbc92b22bc318945dc" + integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== -"@storybook/addon-actions@^6.0.21": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.26.tgz#d0de9e4d78a8f8f5bf8730c04d0b6d1741c29273" - integrity sha512-9tWbAqSwzWWVz8zwAndZFusZYjIcRYgZUC0LqC8QlH79DgF3ASjw9y97+w1VTTAzdb6LYnsMuSpX6+8m5hrR4g== +"@storybook/addon-actions@^6.1.11": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.17.tgz#9d32336284738cefa69b99acafa4b132d5533600" + integrity sha512-4hyAvmjnI4C1ZQ7/t21jKKXE0jO1zAk310BkYin0NJf77Qi0tUE1DNOwirJY/xzRih36wWi1V79c/ZOJNsLv9Q== dependencies: - "@storybook/addons" "6.0.26" - "@storybook/api" "6.0.26" - "@storybook/client-api" "6.0.26" - "@storybook/components" "6.0.26" - "@storybook/core-events" "6.0.26" - "@storybook/theming" "6.0.26" + "@storybook/addons" "6.1.17" + "@storybook/api" "6.1.17" + "@storybook/client-api" "6.1.17" + "@storybook/components" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/theming" "6.1.17" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" lodash "^4.17.15" polished "^3.4.4" prop-types "^15.7.2" - react "^16.8.3" react-inspector "^5.0.1" - regenerator-runtime "^0.13.3" - ts-dedent "^1.1.1" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" uuid "^8.0.0" -"@storybook/addon-links@^6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.0.21.tgz#6d4497933d560615617eaffeacec00ad8a788b01" - integrity sha512-5cRFxXS9BviDbS+DCKElr1vSafDcRhX74iIAWl/yOBUldUZvR+gX3WOZ7bO+OBSlQ1NJkt1NUAMag3aiJa4UUw== +"@storybook/addon-links@^6.1.11": + version "6.1.11" + resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.1.11.tgz#84b20b0c13bbfc8270cdf52389ce52f98c415b6a" + integrity sha512-OIBeOdTHNr/egIuyOUEtEBtZJXVp9PEPQ4MDxvKsCgpnXruPhjenPNW+mQKxm4SefpePW7zkx6k1ubpYVJUeag== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/core-events" "6.0.21" + "@storybook/addons" "6.1.11" + "@storybook/client-logger" "6.1.11" + "@storybook/core-events" "6.1.11" "@storybook/csf" "0.0.1" - "@storybook/router" "6.0.21" + "@storybook/router" "6.1.11" "@types/qs" "^6.9.0" core-js "^3.0.1" global "^4.3.2" prop-types "^15.7.2" qs "^6.6.0" - regenerator-runtime "^0.13.3" - ts-dedent "^1.1.1" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" -"@storybook/addon-storysource@^6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.0.21.tgz#fce9a6de8b276239dbb49b809f3b5efd9fbcecb4" - integrity sha512-h8bu2twPfBRbWlxg8LRtCM5/r2FxWahJa0RC70qDX6eNdzDw6Xv0B8bZsVxKPWqBNQbwYPz5ui44ym53dFDM/Q== +"@storybook/addon-storysource@^6.1.11": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.1.20.tgz#72898b863ec7e11cbe82eb5f740a61530f8439b1" + integrity sha512-MT9ROmjM+WNmzJJrDP52WzJy6DdJHqTCFUaJ5cmUdodmWrwMFTx/MaD53blTU5xjsPQS2pnfEHDDAYESjgJ4bg== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/api" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/components" "6.0.21" - "@storybook/router" "6.0.21" - "@storybook/source-loader" "6.0.21" - "@storybook/theming" "6.0.21" + "@storybook/addons" "6.1.20" + "@storybook/api" "6.1.20" + "@storybook/client-logger" "6.1.20" + "@storybook/components" "6.1.20" + "@storybook/router" "6.1.20" + "@storybook/source-loader" "6.1.20" + "@storybook/theming" "6.1.20" core-js "^3.0.1" estraverse "^4.2.0" loader-utils "^2.0.0" prettier "~2.0.5" prop-types "^15.7.2" - react "^16.9.17" - react-syntax-highlighter "^12.2.1" - regenerator-runtime "^0.13.3" + react-syntax-highlighter "^13.5.0" + regenerator-runtime "^0.13.7" -"@storybook/addons@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.21.tgz#bd5229652102c3aed59b78ef6920ff6b482b4d78" - integrity sha512-yDttNLc3vXqBxwK795ykgzTC6MpvuXDQuF4LHSlHZQe6wsMu1m3fljnbYdafJWdx6cNZwUblU3KYcR11PqhkPg== +"@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== dependencies: - "@storybook/api" "6.0.21" - "@storybook/channels" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/core-events" "6.0.21" - "@storybook/router" "6.0.21" - "@storybook/theming" "6.0.21" + "@storybook/api" "6.1.11" + "@storybook/channels" "6.1.11" + "@storybook/client-logger" "6.1.11" + "@storybook/core-events" "6.1.11" + "@storybook/router" "6.1.11" + "@storybook/theming" "6.1.11" core-js "^3.0.1" global "^4.3.2" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" -"@storybook/addons@6.0.26", "@storybook/addons@^6.0.21": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.26.tgz#343cbea3eee2d39413b80bc2d66535a7f61488fc" - integrity sha512-OhAApFKgsj9an7FLYfHI4cJQuZ4Zm6yoGOpaxhOvKQMw7dXUPsLvbCyw/6dZOLvaFhjJjQiXtbxtZG+UjR8nvA== +"@storybook/addons@6.1.15": + 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.0.26" - "@storybook/channels" "6.0.26" - "@storybook/client-logger" "6.0.26" - "@storybook/core-events" "6.0.26" - "@storybook/router" "6.0.26" - "@storybook/theming" "6.0.26" + "@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.3" + regenerator-runtime "^0.13.7" -"@storybook/api@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.21.tgz#a25a1eb4d07dc43500e03c856db43baba46726f1" - integrity sha512-cRRGf/KGFwYiDouTouEcDdp45N1AbYnAfvLqYZ3KuUTGZ+CiU/PN/vavkp07DQeM4FIQO8TLhzHdsLFpLT7Lkw== +"@storybook/addons@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.17.tgz#ab0666446acb9fc19c94d7204dc9aafdefb6c7c2" + integrity sha512-3upDPJPzUkls2V3Fozzg+JOcv138bF90pbdRe9YSNu37QvRIL+iQODY7oFygMl+kqjG2F1FGw5EvxAV1mnlwCw== + dependencies: + "@storybook/api" "6.1.17" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/router" "6.1.17" + "@storybook/theming" "6.1.17" + core-js "^3.0.1" + global "^4.3.2" + regenerator-runtime "^0.13.7" + +"@storybook/addons@6.1.20", "@storybook/addons@^6.1.11": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.20.tgz#da01dabd6692919b719fcb30519d53ea80887097" + integrity sha512-kIhXYgF+ARNpYxO3qhz8yThDvKpaq+HDst8odPU9sCNEI66PSH6hrILhTmnffNnqdtY3LnKkU9rGVfZn+3TOTA== + dependencies: + "@storybook/api" "6.1.20" + "@storybook/channels" "6.1.20" + "@storybook/client-logger" "6.1.20" + "@storybook/core-events" "6.1.20" + "@storybook/router" "6.1.20" + "@storybook/theming" "6.1.20" + 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" + integrity sha512-/p4QW/p3uWO0AKVveNezX3I/CotyBKaJ5ui8PuvSPsl7yvqcsK41qI4evKOw7GMQn6oP+2enRbzHpGuCUgQSjA== dependencies: "@reach/router" "^1.3.3" - "@storybook/channels" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/core-events" "6.0.21" + "@storybook/channels" "6.1.11" + "@storybook/client-logger" "6.1.11" + "@storybook/core-events" "6.1.11" "@storybook/csf" "0.0.1" - "@storybook/router" "6.0.21" + "@storybook/router" "6.1.11" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.0.21" + "@storybook/theming" "6.1.11" "@types/reach__router" "^1.3.5" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" lodash "^4.17.15" memoizerific "^1.11.3" - react "^16.8.3" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" store2 "^2.7.1" telejson "^5.0.2" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/api@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.26.tgz#c45222c132eb8bc2e383536adfebbeb7a89867d0" - integrity sha512-aszDoz1c6T+eRtTUwWvySoyd3gRXmQxsingD084NnEp4VfFLA5H7VS/0sre0ZvU5GWh8d9COxY0DS2Ry/QSKvw== +"@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.0.26" - "@storybook/client-logger" "6.0.26" - "@storybook/core-events" "6.0.26" + "@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.0.26" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.0.26" - "@types/reach__router" "^1.3.5" + "@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" - react "^16.8.3" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" store2 "^2.7.1" telejson "^5.0.2" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/channel-postmessage@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.21.tgz#97e8f43c1b66f84c7b8271e447d45d4f66d355d1" - integrity sha512-ArRnoaS+b7qpAku/SO27z/yjRDCXb37mCPYGX0ntPbiQajootUbGO7otfnjFkaP44hCEC9uDYlOfMU1hYU1N6A== +"@storybook/api@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.17.tgz#50393ce9b718063b67680212df895eceacc0c11d" + integrity sha512-sthcfuk2EQ3F5R620PBqpI4Pno3g7KQm6YPZA0DXB+LD/z61xH9ToE1gTLF4nzlE6HwghwkXOZyRwDowRdG+7A== dependencies: - "@storybook/channels" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/core-events" "6.0.21" + "@reach/router" "^1.3.3" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.1.17" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.1.17" + "@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/api@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.20.tgz#3738b0c859ead820b378ee94e936abcf0e2f7436" + integrity sha512-+Uvvj7B+0oGb83mOzNjFuxju3ColjJpgyDjNzD5jI2xCtGyau+c8Lr4rhI9xNc2Dw9b8gpfPmhkvEnBPmd/ecQ== + dependencies: + "@reach/router" "^1.3.3" + "@storybook/channels" "6.1.20" + "@storybook/client-logger" "6.1.20" + "@storybook/core-events" "6.1.20" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.1.20" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.1.20" + "@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.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/channel-postmessage@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.26.tgz#a98a0132d6bdf06741afac2607e9feabe34ab98b" - integrity sha512-FT6lC8M5JlNBxPT0rYfmF1yl9mBv04nfYs82TZpp1CzpLxf7wxdCBZ8SSRmvWIVBoNwGZPDhIk5+6JWyDEISBg== +"@storybook/channel-postmessage@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.17.tgz#309ce67c94637ec13319d4ce360a8f3742ddbaf4" + integrity sha512-2nVqxq4oZdSITqhFOnkh1rmDMjCwHuobnK5Fp3l7ftCkbmiZHMheKK9Tz4Rb803dhXvcGYs0zRS8NjKyxlOLsA== dependencies: - "@storybook/channels" "6.0.26" - "@storybook/client-logger" "6.0.26" - "@storybook/core-events" "6.0.26" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" core-js "^3.0.1" global "^4.3.2" qs "^6.6.0" telejson "^5.0.2" -"@storybook/channels@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.21.tgz#bc0951efacbaa5f8827693fba4fe7c2290b5772c" - integrity sha512-G6gjcEotSwDmOlxSmOMgsO3VhQ42RLJK7kFp6D5eg0Q6S8vsypltdT8orxdu+6+AbcBrL+5Sla8lThzaCvXsVQ== +"@storybook/channels@6.1.11": + version "6.1.11" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.11.tgz#a93a83746ad78dd40e1c056029f6d93b17bb66bc" + integrity sha512-NvjWzQ95TSV1e18jaQBCOGoe+vptKH2NOKZ7QRQ7I0O5OoHKr47IXoh+MQ5C8CRD9FTdLE/xWdn1sVVEPRyHEw== dependencies: core-js "^3.0.1" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/channels@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.26.tgz#3e8678b4b40085081257a39b9e85fab13a19943c" - integrity sha512-H0iUorayYqS+zfhbjd+cYRzAdRLGLWUeWFu2Aa+oJ4/zeAQNL+DafWboHc567RQ4Vb5KqE5QZoCFskWUUYqJYA== +"@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 "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.21.tgz#6a652dea67d219a31d18af0e05b9f17ba6c7c316" - integrity sha512-emBXd/ml6pc3G8gP3MsR9zQsAq1zZbqof9MxB51tG/jpTXdqWQ8ce1pt1tJS8Xj0QDM072jR6wsY+mmro0GZnA== +"@storybook/channels@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.17.tgz#2cc89a6b9727d19c24b15fa3cb15569b469db864" + integrity sha512-MUdj0eKr/AbxevHTSXX7AsgxAz6e5O4ZxoYX5G8ggoqSXrWzws6zRFmUmmTdjpIvVmP2M1Kh4SYFAKcS/AGw9w== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/channel-postmessage" "6.0.21" - "@storybook/channels" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/core-events" "6.0.21" + core-js "^3.0.1" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + +"@storybook/channels@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.20.tgz#8dc2763ffda301f3bda811cdcb19f8e88ff4ec80" + integrity sha512-UBvVf07LAUD6JTrk77f4qydS4v5hzjAHJWOfWO6b82oO5bu4hTXt/Rjj/TSz85Rl/NmM4GYAAPIfxJHg53TRTg== + dependencies: + core-js "^3.0.1" + 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.2" + "@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 "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.26.tgz#ac9334ba86834e5cb23fc4fb577de60bda66164d" - integrity sha512-Qd5wR5b5lio/EchuJMhAmmJAE1pfvnEyu+JnyFGwMZLV9mN9NSspz+YsqbSCCDZsYcP5ewvPEnumIWqmj/wagQ== +"@storybook/client-api@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.17.tgz#3ced22f08a47af70ccf8929111bc44b79e9e8ec0" + integrity sha512-Loz/wdh0axgq0PS19tx0tGEFEkFWlYc6YauJGHjygYa1xX7mJ54hDoaTolySCXN1HtfZn08D847yjGSN2oIqVg== dependencies: - "@storybook/addons" "6.0.26" - "@storybook/channel-postmessage" "6.0.26" - "@storybook/channels" "6.0.26" - "@storybook/client-logger" "6.0.26" - "@storybook/core-events" "6.0.26" + "@storybook/addons" "6.1.17" + "@storybook/channel-postmessage" "6.1.17" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" "@storybook/csf" "0.0.1" "@types/qs" "^6.9.0" - "@types/webpack-env" "^1.15.2" + "@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 "^1.1.1" + ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-logger@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.21.tgz#20369addf9eb79fc0c85a2e0dcb48f5a1a544532" - integrity sha512-8aUEbhjXV+UMYQWukVYnp+kZafF+LD4Dm7eMo37IUZvt3VIjV1VvhxIDVJtqjk2vv0KZTepESFBkZQLmBzI9Zg== +"@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" + integrity sha512-dSc+VKLW1UaiMPMhlZYRqhynrrHdHFiBEgU28+8LcmoZ1yhZBwLkcKdSD4YTT0CbMJAG1/+NUW5kRI8Geeg+rA== dependencies: core-js "^3.0.1" global "^4.3.2" -"@storybook/client-logger@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.26.tgz#e3d28bd8dc02ec2c53a9d69773a68189590b746f" - integrity sha512-VNoL6/oehVhn3hZi9vrTNT+C/3oAZKV+smfZFnPtsCR/Fq7CKbmsBd0pGPL57f81RU8e8WygwrIlAGJTDSNIjw== +"@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.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.21.tgz#2f355370f993e0b7b9062094a03dffc2cdda91db" - integrity sha512-r6btqFW/rcXIU5v231EifZfdh9O0fy7bJDXwwDf8zVUgLx8JRc0VnSs3nvK3Is9HF1wZ9vjx/7Lh4rTIDZAjgg== +"@storybook/client-logger@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.17.tgz#0d89aaf824457f19bf9aa585bbcada57595e7d01" + integrity sha512-oqExrxhmws0ihB47sjdynZHd3OpUP4KWkx4udG+74lYIvBH+EZmQ9xF+UofeY3j5p1I9k8ugEcVKy0sqh1yR3w== dependencies: - "@storybook/client-logger" "6.0.21" + core-js "^3.0.1" + global "^4.3.2" + +"@storybook/client-logger@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.20.tgz#f78102bbf4d169c45c5202c1b01cb1e58140be30" + integrity sha512-UKq+5vRXZXcwLgjXEK/NoL61JXar51aSDwnPa4jEFXRpXvIbHZzr6U3TO6r2J2LkTEJO54V2k8F2wnZgUvm3QA== + dependencies: + core-js "^3.0.1" + global "^4.3.2" + +"@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.0.21" + "@storybook/theming" "6.1.15" "@types/overlayscrollbars" "^1.9.0" "@types/react-color" "^3.0.1" "@types/react-syntax-highlighter" "11.0.4" @@ -4226,23 +4920,21 @@ memoizerific "^1.11.3" overlayscrollbars "^1.10.2" polished "^3.4.4" - popper.js "^1.14.7" - react "^16.8.3" react-color "^2.17.0" - react-dom "^16.8.3" - react-popper-tooltip "^2.11.0" - react-syntax-highlighter "^12.2.1" + react-popper-tooltip "^3.1.1" + react-syntax-highlighter "^13.5.0" react-textarea-autosize "^8.1.1" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" -"@storybook/components@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.26.tgz#e1f6e16aae850a71c9ac7bdd1d44a068ec9cfdc1" - integrity sha512-8wigI1pDFJO1m1IQWPguOK+nOsaAVRWkVdu+2te/rDcIR9QNvMzzou0+Lhfp3zKSVT4E6mEoGB/TWXXF5Iq0sQ== +"@storybook/components@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.17.tgz#f92d36e370ec6039d8c7cee9ef13dda866eed3da" + integrity sha512-rIEll0UTxEKmG4IsSS5K+6DjRLVtX8J+9cg79GSAC7N1ZHUR1UQmjjJaehJa5q/NQ5H8C39acxpT4Py/BcsL2g== dependencies: - "@storybook/client-logger" "6.0.26" + "@popperjs/core" "^2.5.4" + "@storybook/client-logger" "6.1.17" "@storybook/csf" "0.0.1" - "@storybook/theming" "6.0.26" + "@storybook/theming" "6.1.17" "@types/overlayscrollbars" "^1.9.0" "@types/react-color" "^3.0.1" "@types/react-syntax-highlighter" "11.0.4" @@ -4254,69 +4946,108 @@ memoizerific "^1.11.3" overlayscrollbars "^1.10.2" polished "^3.4.4" - popper.js "^1.14.7" - react "^16.8.3" react-color "^2.17.0" - react-dom "^16.8.3" - react-popper-tooltip "^2.11.0" - react-syntax-highlighter "^12.2.1" + react-popper-tooltip "^3.1.1" + react-syntax-highlighter "^13.5.0" react-textarea-autosize "^8.1.1" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" -"@storybook/core-events@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.21.tgz#2ce51e6d7524e7543dbb29571beac1dbeb4e5f40" - integrity sha512-p84fbPcsAhnqDhp+HJ4P8+vI2BqJus4IRoVAemLAwuPjyPElrV9UvOa/RHy1BN8Z6jXwFA+FFzfGl2kPJ3WYcA== +"@storybook/components@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.20.tgz#90834d76d50a17172f8c480e0f366ebade31e108" + integrity sha512-X4k2PF3Q60p3rgRkGtjWh0DWP9tqdwMRwDjA6TGj8WyRM2FdROlmH/hwRy9Op/cs2Yj8ApkUJk8AMUm3hBhYvQ== + dependencies: + "@popperjs/core" "^2.5.4" + "@storybook/client-logger" "6.1.20" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.1.20" + "@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" + regenerator-runtime "^0.13.7" + 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" + integrity sha512-hTib81W8PxepM7iXVvl3pBXSaGpChl5LTzaLCoIRO9sSB8dy0/x2DLAHzbQvShk/l1wqUc3TtOLIxq+eC9l3wg== dependencies: core-js "^3.0.1" -"@storybook/core-events@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.26.tgz#61181c9a8610d26cc85d47f133a563879044ca2d" - integrity sha512-nWjS/+kMiw31OPgeJQaiFsJk9ZJJo3/d4c+kc6GOl2iC1H3Q4/5cm3NvJBn/7bUtKHmSFwfbDouj+XjUk5rZbQ== +"@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.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/core/-/core-6.0.21.tgz#105c2b90ab27e7b478cb1b7d10e9fe5aba5e0708" - integrity sha512-/Et5NLabB12dnuPdhHDA/Q1pj0Mm2DGdL3KiLO4IC2VZeICCLGmU3/EGJBgjLK+anQ59pkclOiQ8i9eMXFiJ6A== +"@storybook/core-events@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.17.tgz#697ed916fcb2a411bc9f8bdbfacd0eb9d394eb58" + integrity sha512-xBI7kmyROcqhYNmFv4QBjD77CzV+k/0F051YFS5WicEI4qDWPPvzaShhm96ZrGobUX3+di4pC11gqdsrFeNCEg== dependencies: - "@babel/plugin-proposal-class-properties" "^7.8.3" - "@babel/plugin-proposal-decorators" "^7.8.3" - "@babel/plugin-proposal-export-default-from" "^7.8.3" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.1" - "@babel/plugin-proposal-object-rest-spread" "^7.9.6" - "@babel/plugin-proposal-optional-chaining" "^7.10.1" - "@babel/plugin-proposal-private-methods" "^7.8.3" + core-js "^3.0.1" + +"@storybook/core-events@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.20.tgz#a23fe6ff858c0a4c48f89beaca1e50be5ba0b598" + integrity sha512-OPKNCbETTrGGypxFzDtsE2cGdHDNolVSJv1mZ17fr9lquc5eyJJCAJ4HbPk+OocRuHBKEnc1/pcA+wWKBM+vnA== + 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" + "@babel/plugin-proposal-decorators" "^7.12.1" + "@babel/plugin-proposal-export-default-from" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.1" + "@babel/plugin-proposal-private-methods" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-transform-arrow-functions" "^7.8.3" - "@babel/plugin-transform-block-scoping" "^7.8.3" - "@babel/plugin-transform-classes" "^7.9.5" - "@babel/plugin-transform-destructuring" "^7.9.5" - "@babel/plugin-transform-for-of" "^7.9.0" - "@babel/plugin-transform-parameters" "^7.9.5" - "@babel/plugin-transform-shorthand-properties" "^7.8.3" - "@babel/plugin-transform-spread" "^7.8.3" - "@babel/plugin-transform-template-literals" "^7.8.3" - "@babel/preset-env" "^7.9.6" - "@babel/preset-react" "^7.8.3" - "@babel/preset-typescript" "^7.9.0" - "@babel/register" "^7.10.5" - "@storybook/addons" "6.0.21" - "@storybook/api" "6.0.21" - "@storybook/channel-postmessage" "6.0.21" - "@storybook/channels" "6.0.21" - "@storybook/client-api" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/components" "6.0.21" - "@storybook/core-events" "6.0.21" + "@babel/plugin-transform-arrow-functions" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.1" + "@babel/plugin-transform-classes" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@babel/plugin-transform-spread" "^7.12.1" + "@babel/plugin-transform-template-literals" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.1" + "@babel/preset-typescript" "^7.12.1" + "@babel/register" "^7.12.1" + "@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.0.21" - "@storybook/router" "6.0.21" + "@storybook/node-logger" "6.1.15" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.0.21" - "@storybook/ui" "6.0.21" + "@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" @@ -4334,6 +5065,7 @@ cli-table3 "0.6.0" commander "^5.0.0" core-js "^3.0.1" + cpy "^8.1.1" css-loader "^3.5.3" detect-port "^1.3.0" dotenv-webpack "^1.7.0" @@ -4364,19 +5096,21 @@ qs "^6.6.0" raw-loader "^4.0.1" react-dev-utils "^10.0.0" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" resolve-from "^5.0.0" serve-favicon "^2.5.0" - shelljs "^0.8.3" + shelljs "^0.8.4" stable "^0.1.8" style-loader "^1.2.1" + telejson "^5.0.2" terser-webpack-plugin "^3.0.0" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" unfetch "^4.1.0" url-loader "^4.0.0" util-deprecate "^1.0.2" - webpack "^4.43.0" + webpack "^4.44.2" webpack-dev-middleware "^3.7.0" + webpack-filter-warnings-plugin "^1.2.1" webpack-hot-middleware "^2.25.0" webpack-virtual-modules "^0.2.2" @@ -4387,10 +5121,10 @@ dependencies: lodash "^4.17.15" -"@storybook/node-logger@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.0.21.tgz#5b8ba589d5cca6a67c69ee8f5258755b7e1dbc08" - integrity sha512-KRBf+Fz7fgtwHdnYt70JTZbcYMZ1pQPtDyqbrFYCjwkbx5GPX5vMOozlxCIj9elseqPIsF8CKgHOW7cFHVyWYw== +"@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" @@ -4398,36 +5132,37 @@ npmlog "^4.1.2" pretty-hrtime "^1.0.3" -"@storybook/react@^6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/react/-/react-6.0.21.tgz#68f8a318e9940305b06eb894896624a35a9868b0" - integrity sha512-L3PcoBJq5aK1aTaJNfwsSJ8Kxgcyk0WknN4TDqhP7a+oXmuMY1YEi96hEvQVIm0TBCkQxs61K70/T7vlilEtHg== +"@storybook/react@^6.1.11": + 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.0.0" - "@babel/preset-react" "^7.0.0" - "@storybook/addons" "6.0.21" - "@storybook/core" "6.0.21" - "@storybook/node-logger" "6.0.21" + "@babel/preset-flow" "^7.12.1" + "@babel/preset-react" "^7.12.1" + "@pmmmwh/react-refresh-webpack-plugin" "^0.4.2" + "@storybook/addons" "6.1.15" + "@storybook/core" "6.1.15" + "@storybook/node-logger" "6.1.15" "@storybook/semver" "^7.3.2" - "@svgr/webpack" "^5.4.0" - "@types/webpack-env" "^1.15.2" + "@types/webpack-env" "^1.15.3" babel-plugin-add-react-displayname "^0.0.5" babel-plugin-named-asset-import "^0.3.1" - babel-plugin-react-docgen "^4.1.0" + babel-plugin-react-docgen "^4.2.1" core-js "^3.0.1" global "^4.3.2" lodash "^4.17.15" prop-types "^15.7.2" react-dev-utils "^10.0.0" - react-docgen-typescript-plugin "^0.5.2" - regenerator-runtime "^0.13.3" - ts-dedent "^1.1.1" - webpack "^4.43.0" + react-docgen-typescript-plugin "^0.6.2" + react-refresh "^0.8.3" + regenerator-runtime "^0.13.7" + ts-dedent "^2.0.0" + webpack "^4.44.2" -"@storybook/router@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/router/-/router-6.0.21.tgz#0f22261d4782c72a5a13e80cfcd8d50aed1f98c6" - integrity sha512-46SsKJfcd12lRrISnfrWhicJx8EylkgGDGohfH0n5p7inkkGOkKV8QFZoYPRKZueMXmUKpzJ0Z3HmVsLTCrCDw== +"@storybook/router@6.1.11": + version "6.1.11" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.11.tgz#d58e0c8324d8b16d71e09c017a4e3c844b1a4139" + integrity sha512-YEYOoKMo/WI13MZCkdqI9X3H1G0Oj5OUxi7So4qd3khX3zcCjSr3LjiMDBcmIVZpFo5VAvzjhIY4KqpgvzTG0A== dependencies: "@reach/router" "^1.3.3" "@types/reach__router" "^1.3.5" @@ -4436,13 +5171,37 @@ memoizerific "^1.11.3" qs "^6.6.0" -"@storybook/router@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/router/-/router-6.0.26.tgz#5b991001afa7d7eb5e40c53cd4c58266b6f9edfd" - integrity sha512-kQ1LF/2gX3IkjS1wX7CsoeBc9ptHQzOsyax16rUyJa769DT5vMNtFtQxjNXMqSiSapPg2yrXJFKQNaoWvKgQEQ== +"@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.5" + "@types/reach__router" "^1.3.7" + core-js "^3.0.1" + global "^4.3.2" + memoizerific "^1.11.3" + qs "^6.6.0" + +"@storybook/router@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.17.tgz#96746878c50c6c97c7de5a1b23a9503c5d648775" + integrity sha512-wLqSOB5yLXgNyDGy008RUvjVRtVMq7lhmMRicSIxgJpkakPrMRN8n/nK7pxgQc/xDTphnS0u1nT01i97WszhCg== + 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/router@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.20.tgz#8d27379f53439762f503d77ce4ec2e9ac80644b4" + integrity sha512-hIJiy60znxu9fJgnFP3n5C9YdWr/bHk77vowf0nO0v+dd59EKlgh7ibiDi48Fe2PMU95pYGb6mCDouNS+boN0w== + 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" @@ -4456,13 +5215,13 @@ core-js "^3.6.5" find-up "^4.1.0" -"@storybook/source-loader@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.0.21.tgz#f4ae0fa3f3d119f9ace1d3364df21f8f7cf65bd7" - integrity sha512-Duzpz8udadR7wzH8/4F3GnMRe23oBOxTm4jBZw/T8NA+HqBtd9Y16swWw4BfwsRwfdZS4EVw3PtWgsAfoqF7ow== +"@storybook/source-loader@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.1.20.tgz#884f982430a422063a8a0edee0af1d72dabc75d2" + integrity sha512-rxfh+6JoPrw9RIB+yQ81VpRt586rlLC6mNeGthuwq1KLrw6j4B6Uk3VK0zE1mWdqVfVZZH3SuzM/KEGK86XlTg== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/client-logger" "6.0.21" + "@storybook/addons" "6.1.20" + "@storybook/client-logger" "6.1.20" "@storybook/csf" "0.0.1" core-js "^3.0.1" estraverse "^4.2.0" @@ -4470,17 +5229,18 @@ loader-utils "^2.0.0" lodash "^4.17.15" prettier "~2.0.5" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" + source-map "^0.7.3" -"@storybook/theming@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.0.21.tgz#d56051c0b8679c2b701ce08385660ab4146cf15f" - integrity sha512-n97DfB9kG6WrV1xBGDyeQibTrh8pBBCp3dSL3UTGH+KX3C2+4sm6QHlTgyekbi5FrbFEbnuZOKAS3YbLVONsRQ== +"@storybook/theming@6.1.11": + version "6.1.11" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.11.tgz#0e757e0562aedefc96bd1d12714d4c8e92823467" + integrity sha512-zRChacVgKoU2BmpvwK1ntiF3KIpc8QblJT7IGiKfP/BNpy9gNeXbLPLk3g/tkHszOvVYtkaZhEXni4Od8tqy1A== dependencies: - "@emotion/core" "^10.0.20" + "@emotion/core" "^10.1.1" "@emotion/is-prop-valid" "^0.8.6" - "@emotion/styled" "^10.0.17" - "@storybook/client-logger" "6.0.21" + "@emotion/styled" "^10.0.23" + "@storybook/client-logger" "6.1.11" core-js "^3.0.1" deep-object-diff "^1.1.0" emotion-theming "^10.0.19" @@ -4488,17 +5248,17 @@ memoizerific "^1.11.3" polished "^3.4.4" resolve-from "^5.0.0" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" -"@storybook/theming@6.0.26": - version "6.0.26" - resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.0.26.tgz#e5b545fb2653dfd1b043b567197d490b1c3c0da3" - integrity sha512-9yon2ofb9a+RT1pdvn8Njydy7XRw0qXcIsMqGsJRKoZecmRRozqB6DxH9Gbdf1vRSbM9gYUUDjbiMDFz7+4RiQ== +"@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.0.20" + "@emotion/core" "^10.1.1" "@emotion/is-prop-valid" "^0.8.6" - "@emotion/styled" "^10.0.17" - "@storybook/client-logger" "6.0.26" + "@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" @@ -4506,27 +5266,64 @@ memoizerific "^1.11.3" polished "^3.4.4" resolve-from "^5.0.0" - ts-dedent "^1.1.1" + ts-dedent "^2.0.0" -"@storybook/ui@6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.0.21.tgz#5dac2b68a30f5dba5457e0315f58977e07138968" - integrity sha512-50QYF8tHUgpVq7B7PWp7kmyf79NySWJO0piQFjHv027vV8GfbXMWVswAXwo3IfCihPlnLKe01WbsigM/9T1HCQ== +"@storybook/theming@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.17.tgz#99cc120a230c30458d833b40c806b9b4dff7b34a" + integrity sha512-LpRuY2aIh2td+qZi7E8cp2oM88LudNMmTsBT6N2/Id69u/a9qQd2cYCA9k9fAsg7rjor+wR/N695jk3SGtoFTw== dependencies: - "@emotion/core" "^10.0.20" - "@storybook/addons" "6.0.21" - "@storybook/api" "6.0.21" - "@storybook/channels" "6.0.21" - "@storybook/client-logger" "6.0.21" - "@storybook/components" "6.0.21" - "@storybook/core-events" "6.0.21" - "@storybook/router" "6.0.21" + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.23" + "@storybook/client-logger" "6.1.17" + 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/theming@6.1.20": + version "6.1.20" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.20.tgz#ed0b330a5c08bbe998e9df95e615f0e84a8d663f" + integrity sha512-yg56fa4uhXs+oNmwSHw/jAt1sWpAfq2k6aP1FOtWiEI372g7ZYddP/0ENoj07R+8jZxkvafLNhMI20aIxXpvTQ== + dependencies: + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.23" + "@storybook/client-logger" "6.1.20" + 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.0.21" + "@storybook/theming" "6.1.15" "@types/markdown-to-jsx" "^6.11.0" copy-to-clipboard "^3.0.8" core-js "^3.0.1" core-js-pure "^3.0.1" + downshift "^6.0.6" emotion-theming "^10.0.19" fuse.js "^3.6.1" global "^4.3.2" @@ -4535,115 +5332,14 @@ memoizerific "^1.11.3" polished "^3.4.4" qs "^6.6.0" - react "^16.8.3" - react-dom "^16.8.3" react-draggable "^4.0.3" react-helmet-async "^1.0.2" react-hotkeys "2.0.0" react-sizeme "^2.6.7" - regenerator-runtime "^0.13.3" + regenerator-runtime "^0.13.7" resolve-from "^5.0.0" store2 "^2.7.1" -"@styled-system/background@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz#75c63d06b497ab372b70186c0bf608d62847a2ba" - integrity sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/border@^5.1.5": - version "5.1.5" - resolved "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz#0493d4332d2b59b74bb0d57d08c73eb555761ba6" - integrity sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/color@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz#b8d6b4af481faabe4abca1a60f8daa4ccc2d9f43" - integrity sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/core@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz#b8b7b86455d5a0514f071c4fa8e434b987f6a772" - integrity sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw== - dependencies: - object-assign "^4.1.1" - -"@styled-system/css@^5.1.5": - version "5.1.5" - resolved "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz#0460d5f3ff962fa649ea128ef58d9584f403bbbc" - integrity sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A== - -"@styled-system/flexbox@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz#077090f43f61c3852df63da24e4108087a8beecf" - integrity sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/grid@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz#7165049877732900b99cd00759679fbe45c6c573" - integrity sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/layout@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz#12d73e79887e10062f4dbbbc2067462eace42339" - integrity sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/position@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz#56961266566836f57a24d8e8e33ce0c1adb59dd3" - integrity sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/shadow@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz#beddab28d7de03cd0177a87ac4ed3b3b6d9831fd" - integrity sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/should-forward-prop@^5.1.2": - version "5.1.5" - resolved "https://registry.npmjs.org/@styled-system/should-forward-prop/-/should-forward-prop-5.1.5.tgz#c392008c6ae14a6eb78bf1932733594f7f7e5c76" - integrity sha512-+rPRomgCGYnUIaFabDoOgpSDc4UUJ1KsmlnzcEp0tu5lFrBQKgZclSo18Z1URhaZm7a6agGtS5Xif7tuC2s52Q== - dependencies: - "@emotion/is-prop-valid" "^0.8.1" - "@emotion/memoize" "^0.7.1" - styled-system "^5.1.5" - -"@styled-system/space@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz#38925d2fa29a41c0eb20e65b7c3efb6e8efce953" - integrity sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/typography@^5.1.2": - version "5.1.2" - resolved "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz#65fb791c67d50cd2900d234583eaacdca8c134f7" - integrity sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg== - dependencies: - "@styled-system/core" "^5.1.2" - -"@styled-system/variant@^5.1.5": - version "5.1.5" - resolved "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz#8446d8aad06af3a4c723d717841df2dbe4ddeafd" - integrity sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw== - dependencies: - "@styled-system/core" "^5.1.2" - "@styled-system/css" "^5.1.5" - "@sucrase/webpack-loader@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@sucrase/webpack-loader/-/webpack-loader-2.0.0.tgz#b8a70b8d3df3eeb570e6a3315e1a9c6b723e4a37" @@ -4686,15 +5382,15 @@ resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz#00bf9a7a73f1cad3948cdab1f8dfb774750f8c80" integrity sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q== -"@svgr/babel-plugin-transform-svg-component@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.4.0.tgz#a2212b4d018e6075a058bb7e220a66959ef7a03c" - integrity sha512-zLl4Fl3NvKxxjWNkqEcpdSOpQ3LGVH2BNFQ6vjaK6sFo2IrSznrhURIPI0HAphKiiIwNYjAfE0TNoQDSZv0U9A== +"@svgr/babel-plugin-transform-svg-component@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz#583a5e2a193e214da2f3afeb0b9e8d3250126b4a" + integrity sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ== -"@svgr/babel-preset@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.4.0.tgz#da21854643e1c4ad2279239baa7d5a8b128c1f15" - integrity sha512-Gyx7cCxua04DBtyILTYdQxeO/pwfTBev6+eXTbVbxe4HTGhOUW6yo7PSbG2p6eJMl44j6XSequ0ZDP7bl0nu9A== +"@svgr/babel-preset@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz#8af54f3e0a8add7b1e2b0fcd5a882c55393df327" + integrity sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig== dependencies: "@svgr/babel-plugin-add-jsx-attribute" "^5.4.0" "@svgr/babel-plugin-remove-jsx-attribute" "^5.4.0" @@ -4703,35 +5399,35 @@ "@svgr/babel-plugin-svg-dynamic-title" "^5.4.0" "@svgr/babel-plugin-svg-em-dimensions" "^5.4.0" "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" - "@svgr/babel-plugin-transform-svg-component" "^5.4.0" + "@svgr/babel-plugin-transform-svg-component" "^5.5.0" -"@svgr/core@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/core/-/core-5.4.0.tgz#655378ee43679eb94fee3d4e1976e38252dff8e7" - integrity sha512-hWGm1DCCvd4IEn7VgDUHYiC597lUYhFau2lwJBYpQWDirYLkX4OsXu9IslPgJ9UpP7wsw3n2Ffv9sW7SXJVfqQ== +"@svgr/core@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz#82e826b8715d71083120fe8f2492ec7d7874a579" + integrity sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ== dependencies: - "@svgr/plugin-jsx" "^5.4.0" - camelcase "^6.0.0" - cosmiconfig "^6.0.0" + "@svgr/plugin-jsx" "^5.5.0" + camelcase "^6.2.0" + cosmiconfig "^7.0.0" -"@svgr/hast-util-to-babel-ast@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.4.0.tgz#bb5d002e428f510aa5b53ec0a02377a95b367715" - integrity sha512-+U0TZZpPsP2V1WvVhqAOSTk+N+CjYHdZx+x9UBa1eeeZDXwH8pt0CrQf2+SvRl/h2CAPRFkm+Ey96+jKP8Bsgg== +"@svgr/hast-util-to-babel-ast@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz#5ee52a9c2533f73e63f8f22b779f93cd432a5461" + integrity sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ== dependencies: - "@babel/types" "^7.9.5" + "@babel/types" "^7.12.6" -"@svgr/plugin-jsx@5.4.x", "@svgr/plugin-jsx@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.4.0.tgz#ab47504c55615833c6db70fca2d7e489f509787c" - integrity sha512-SGzO4JZQ2HvGRKDzRga9YFSqOqaNrgLlQVaGvpZ2Iht2gwRp/tq+18Pvv9kS9ZqOMYgyix2LLxZMY1LOe9NPqw== +"@svgr/plugin-jsx@5.5.x", "@svgr/plugin-jsx@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz#1aa8cd798a1db7173ac043466d7b52236b369000" + integrity sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA== dependencies: - "@babel/core" "^7.7.5" - "@svgr/babel-preset" "^5.4.0" - "@svgr/hast-util-to-babel-ast" "^5.4.0" + "@babel/core" "^7.12.3" + "@svgr/babel-preset" "^5.5.0" + "@svgr/hast-util-to-babel-ast" "^5.5.0" svg-parser "^2.0.2" -"@svgr/plugin-svgo@5.4.x", "@svgr/plugin-svgo@^5.4.0": +"@svgr/plugin-svgo@5.4.x": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.4.0.tgz#45d9800b7099a6f7b4d85ebac89ab9abe8592f64" integrity sha512-3Cgv3aYi1l6SHyzArV9C36yo4kgwVdF3zPQUC6/aCDUeXAofDYwE5kk3e3oT5ZO2a0N3lB+lLGvipBG6lnG8EA== @@ -4740,32 +5436,41 @@ merge-deep "^3.0.2" svgo "^1.2.2" -"@svgr/rollup@5.4.x": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/rollup/-/rollup-5.4.0.tgz#b1946c8d2c13870397af014a105d40945b7371e5" - integrity sha512-hwYjrTddW6mFU9vwqRr1TULNvxiIxGdIbqrD5J7vtoATSfWazq/2JSnT4BmiH+/4kFXLEtjKuSKoDUotkOIAkg== +"@svgr/plugin-svgo@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz#02da55d85320549324e201c7b2e53bf431fcc246" + integrity sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ== dependencies: - "@babel/core" "^7.7.5" - "@babel/plugin-transform-react-constant-elements" "^7.7.4" - "@babel/preset-env" "^7.9.5" - "@babel/preset-react" "^7.9.4" - "@svgr/core" "^5.4.0" - "@svgr/plugin-jsx" "^5.4.0" - "@svgr/plugin-svgo" "^5.4.0" + cosmiconfig "^7.0.0" + deepmerge "^4.2.2" + svgo "^1.2.2" + +"@svgr/rollup@5.5.x": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/rollup/-/rollup-5.5.0.tgz#9ceaaa6d463916e69aff8a9e10b3bb8fbb94688a" + integrity sha512-EiZmH2VTr+Xzyb6Ga8XtGa9MEbiU3WQnB5vHmqhwAUqibU3uwuwr7MN+QwIh/gtBk1ucMim8BCfcRTlLVREM8A== + dependencies: + "@babel/core" "^7.12.3" + "@babel/plugin-transform-react-constant-elements" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.5" + "@svgr/core" "^5.5.0" + "@svgr/plugin-jsx" "^5.5.0" + "@svgr/plugin-svgo" "^5.5.0" rollup-pluginutils "^2.8.2" -"@svgr/webpack@5.4.x", "@svgr/webpack@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.4.0.tgz#b68bc86e29cf007292b96ced65f80971175632e0" - integrity sha512-LjepnS/BSAvelnOnnzr6Gg0GcpLmnZ9ThGFK5WJtm1xOqdBE/1IACZU7MMdVzjyUkfFqGz87eRE4hFaSLiUwYg== +"@svgr/webpack@5.5.x": + version "5.5.0" + resolved "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz#aae858ee579f5fa8ce6c3166ef56c6a1b381b640" + integrity sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g== dependencies: - "@babel/core" "^7.9.0" - "@babel/plugin-transform-react-constant-elements" "^7.9.0" - "@babel/preset-env" "^7.9.5" - "@babel/preset-react" "^7.9.4" - "@svgr/core" "^5.4.0" - "@svgr/plugin-jsx" "^5.4.0" - "@svgr/plugin-svgo" "^5.4.0" + "@babel/core" "^7.12.3" + "@babel/plugin-transform-react-constant-elements" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.5" + "@svgr/core" "^5.5.0" + "@svgr/plugin-jsx" "^5.5.0" + "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" "@szmarczak/http-timer@^1.1.2": @@ -4775,7 +5480,7 @@ dependencies: defer-to-connect "^1.0.1" -"@szmarczak/http-timer@^4.0.0", "@szmarczak/http-timer@^4.0.5": +"@szmarczak/http-timer@^4.0.5": version "4.0.5" resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ== @@ -4790,16 +5495,19 @@ "@babel/runtime" "^7.11.2" "@testing-library/dom" "^7.22.2" -"@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": - version "7.23.0" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.23.0.tgz#c54c0fa53705ad867bcefb52fc0c96487fbc10f6" - integrity sha512-H5m090auYH+obdZmsaYLrSWC5OauWD2CvNbz88KBxQJoXgkJzbU0DpAG8BS7Evj5WqCC3nAAKrLS6vw0ljUYLg== +"@testing-library/dom@^7.22.2", "@testing-library/dom@^7.28.1": + version "7.29.6" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.29.6.tgz#eb37844fb431186db7960a7ff6749ea65a19617c" + integrity sha512-vzTsAXa439ptdvav/4lsKRcGpAQX7b6wBIqia7+iNzqGJ5zjswApxA6jDAsexrc6ue9krWcbh8o+LYkBXW+GCQ== dependencies: - "@babel/runtime" "^7.10.3" + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" "@types/aria-query" "^4.2.0" aria-query "^4.2.2" - dom-accessibility-api "^0.5.1" - pretty-format "^26.4.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.4" + lz-string "^1.4.4" + pretty-format "^26.6.2" "@testing-library/jest-dom@^5.10.1": version "5.11.4" @@ -4831,13 +5539,13 @@ "@babel/runtime" "^7.5.4" "@types/testing-library__react-hooks" "^3.4.0" -"@testing-library/react@^10.4.1": - version "10.4.3" - resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4" - integrity sha512-A/ydYXcwAcfY7vkPrfUkUTf9HQLL3/GtixTefcu3OyGQtAYQ7XBQj1S9FWbLEhfWa0BLwFwTBFS3Ao1O0tbMJg== +"@testing-library/react@^11.2.5": + version "11.2.5" + resolved "https://registry.npmjs.org/@testing-library/react/-/react-11.2.5.tgz#ae1c36a66c7790ddb6662c416c27863d87818eb9" + integrity sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ== dependencies: - "@babel/runtime" "^7.10.3" - "@testing-library/dom" "^7.17.1" + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^7.28.1" "@testing-library/user-event@^12.0.7": version "12.0.7" @@ -4846,61 +5554,6 @@ dependencies: "@babel/runtime" "^7.10.2" -"@theme-ui/color-modes@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@theme-ui/color-modes/-/color-modes-0.3.1.tgz#8c2b18fb170e6c998287b7381240a8b9cca8b0d1" - integrity sha512-WuZGgFW7M5wOWSse1PVZCEfM0OZip15/D6U3bB3B9KmWax7qiSnAm1yAMLRQKC+QYhndrjq3xU+WAQm11KnhIw== - dependencies: - "@emotion/core" "^10.0.0" - "@theme-ui/core" "^0.3.1" - "@theme-ui/css" "^0.3.1" - deepmerge "^4.2.2" - -"@theme-ui/components@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@theme-ui/components/-/components-0.3.1.tgz#fe023e156c1e1c076d5f2258466426e94adc2765" - integrity sha512-uG4dUM61s4tWv6N34uxs5VIh24bJyA/7TrYJ75WDiI+s72zbcNG7aGRpvX/hSZnAhxjdXpuskdEM3eEgOabdEg== - dependencies: - "@emotion/core" "^10.0.0" - "@emotion/styled" "^10.0.0" - "@styled-system/color" "^5.1.2" - "@styled-system/should-forward-prop" "^5.1.2" - "@styled-system/space" "^5.1.2" - "@theme-ui/css" "^0.3.1" - -"@theme-ui/core@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@theme-ui/core/-/core-0.3.1.tgz#dbe9800b9d6e923e1a7417e6adebce21524f8c02" - integrity sha512-cK6EVSOx0Kyx1Xpi4qb0JTLIxywx0DRh+53Ln1foXMplF2qKaDsFi3vD6duHIlT331E3CNOa9dftHHNM7y4rbA== - dependencies: - "@emotion/core" "^10.0.0" - "@theme-ui/css" "^0.3.1" - deepmerge "^4.2.2" - -"@theme-ui/css@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@theme-ui/css/-/css-0.3.1.tgz#b85c7e8fae948dc0de65aa30b853368993e25cb3" - integrity sha512-QB2/fZBpo4inaLHL3OrB8NOBgNfwnj8GtHzXWHb9iQSRjmtNX8zPXBe32jLT7qQP0+y8JxPT4YChZIkm5ZyIdg== - -"@theme-ui/mdx@^0.3.0": - version "0.3.0" - resolved "https://registry.npmjs.org/@theme-ui/mdx/-/mdx-0.3.0.tgz#8bb1342204acfaa69914d6b6567c5c49d9a8c1e6" - integrity sha512-/GHBNKqmUptWwkmF+zIASVQtjYs81XMEwtqPCHnHuaaCzhZxcXrtCwvcAgmCXF8hpRttCXVVxw1X3Gt0mhzaTQ== - dependencies: - "@emotion/core" "^10.0.0" - "@emotion/styled" "^10.0.0" - "@mdx-js/react" "^1.0.0" - -"@theme-ui/theme-provider@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@theme-ui/theme-provider/-/theme-provider-0.3.1.tgz#910bc43454fd61b1047d7bb0dce05e36ffb6b44b" - integrity sha512-Sjj6lD0gPxBi+hcGCkawcGZECeESV/mW2YfmPqjNgmc296x5tulfNc+0/N5CJwLVOmnkn8zR5KNWZ8BjndfeTg== - dependencies: - "@emotion/core" "^10.0.0" - "@theme-ui/color-modes" "^0.3.1" - "@theme-ui/core" "^0.3.1" - "@theme-ui/mdx" "^0.3.0" - "@tootallnate/once@1": version "1.1.2" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -4918,10 +5571,10 @@ resolved "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== -"@types/archiver@^3.1.1": - version "3.1.1" - resolved "https://registry.npmjs.org/@types/archiver/-/archiver-3.1.1.tgz#10cc1be44af8911e57484342c7b3b32a5f178a1a" - integrity sha512-TzVZ9204sH1TuFylfr1cw/AA/3/VldAAXswEwKLXUOzA9mDg+m6gHF9EaqKNlozcjc6knX5m1KAqJzksPLSEfw== +"@types/archiver@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.0.tgz#869f4ce4028e49cf9a0243cf914415f4cc3d1f3d" + integrity sha512-baFOhanb/hxmcOd1Uey2TfFg43kTSmM6py1Eo7Rjbv/ivcl7PXLhY0QgXGf50Hx/eskGCFqPfhs/7IZLb15C5g== dependencies: "@types/glob" "*" @@ -4930,6 +5583,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" @@ -4999,9 +5657,9 @@ integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== "@types/classnames@^2.2.9": - version "2.2.10" - resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" - integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== + version "2.2.11" + resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" + integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== "@types/clean-css@*": version "4.2.1" @@ -5102,13 +5760,18 @@ resolved "https://registry.npmjs.org/@types/core-js/-/core-js-2.5.4.tgz#fc42ebde7d9cfa7c5f2668f117449b02348e41fd" integrity sha512-Xwy8o12ak+iYgFr/KCVaVK5Sy+jFMiiPAID3+ObvMlBzy26XQJw5xu+a6rlHsrJENXj/AwJOGsJpVohUjAzSKQ== -"@types/cors@^2.8.4", "@types/cors@^2.8.6": - version "2.8.6" - resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz#cfaab33c49c15b1ded32f235111ce9123009bd02" - integrity sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg== +"@types/cors@2.8.8": + version "2.8.8" + resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.8.tgz#317a8d8561995c60e35b9e0fcaa8d36660c98092" + integrity sha512-fO3gf3DxU2Trcbr75O7obVndW/X5k8rJNZkLXlQWStTHhP71PkRqjwPIEI0yMnJdg9R9OasjU+Bsr+Hr1xy/0w== dependencies: "@types/express" "*" +"@types/cors@^2.8.6": + version "2.8.10" + resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz#61cc8469849e5bcdd0c7044122265c39cec10cf4" + integrity sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ== + "@types/cssnano@*": version "4.0.0" resolved "https://registry.npmjs.org/@types/cssnano/-/cssnano-4.0.0.tgz#f1bb29d6d0813861a3d87e02946b2988d0110d4e" @@ -5121,10 +5784,10 @@ resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.0.tgz#febdfadade56e215a4c3f612fe3000d92999f5d5" integrity sha512-Bs0maTeU47rdZT+n42iQ0C4gnbnJlIDJkqHFtIsDx2tPPITDeoSdIrm+00UYXzegzArYC2GsG80eHNMwz08IAw== -"@types/d3-force@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" - integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-force@^2.1.1": + version "2.1.1" + resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-2.1.1.tgz#a18b6f029d056eb0f8f84a09471e6228e4469b14" + integrity sha512-3r+CQv2K/uDTAVg0DGxsbBjV02vgOxb8RhPIv3gd6cp3pdPAZ7wEXpDjUZSoqycAQLSDOxG/AZ54Vx6YXZSbmQ== "@types/d3-interpolate@*": version "2.0.0" @@ -5180,18 +5843,13 @@ resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ== -"@types/dockerode@^2.5.32", "@types/dockerode@^2.5.34": - version "2.5.34" - resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.34.tgz#9adb884f7cc6c012a6eb4b2ad794cc5d01439959" - integrity sha512-LcbLGcvcBwBAvjH9UrUI+4qotY+A5WCer5r43DR5XHv2ZIEByNXFdPLo1XxR+v/BjkGjlggW8qUiXuVEhqfkpA== +"@types/dockerode@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.1.tgz#f713a8f6f1017c227845ab33239383da721207b9" + integrity sha512-AeZpdQMNqM8dtrEaaP81CbjdRVKNmFIMzgz5IlKIeS5uWEmjlEmENP444AGTEEF71r5TFuY9E4SkzZAO8lOF1A== 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" @@ -5210,10 +5868,10 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" 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== +"@types/express-serve-static-core@*", "@types/express-serve-static-core@4.17.18", "@types/express-serve-static-core@^4.17.5": + 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" "*" @@ -5257,6 +5915,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.5": + version "9.0.5" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.5.tgz#2afb76a43a4bef80a363b94b314d0ca1694fc4f8" + integrity sha512-wr3t7wIW1c0A2BIJtdVp4EflriVaVVAsCAIHVzzh8B+GiFv9X1xeJjCs4upRXtzp7kQ6lP5xvskjoD4awJ1ZeA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -5292,22 +5957,12 @@ dependencies: "@types/node" "*" -"@types/graphql-upload@^8.0.0": - version "8.0.3" - resolved "https://registry.npmjs.org/@types/graphql-upload/-/graphql-upload-8.0.3.tgz#b371edb5f305a2a1f7b7843a890a2a7adc55c3ec" - integrity sha512-hmLg9pCU/GmxBscg8GCr1vmSoEmbItNNxdD5YH2TJkXm//8atjwuprB+xJBK714JG1dkxbbhp5RHX+Pz1KsCMA== +"@types/hast@^2.0.0": + version "2.3.1" + resolved "https://registry.npmjs.org/@types/hast/-/hast-2.3.1.tgz#b16872f2a6144c7025f296fb9636a667ebb79cd9" + integrity sha512-viwwrB+6xGzw+G1eWpF9geV3fnsDgXqHG+cqgiHrvQfDUW5hzhCyV7Sy3UJxhfRFBsgky2SSW33qi/YrIkjX5Q== dependencies: - "@types/express" "*" - "@types/fs-capacitor" "*" - "@types/koa" "*" - graphql "^14.5.3" - -"@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/unist" "*" "@types/history@*": version "4.7.5" @@ -5369,12 +6024,17 @@ "@types/node" "*" "@types/http-proxy@*", "@types/http-proxy@^1.17.4": - version "1.17.4" - resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" - integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q== + version "1.17.5" + resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.5.tgz#c203c5e6e9dc6820d27a40eb1e511c70a220423d" + integrity sha512-GNkDE7bTv6Sf8JbV2GksknKOsk7OznNYHSdrtvPJXO0qJ9odZig6IZKUi5RFGi6d1bf6dgIAe4uXi3DBc7069Q== dependencies: "@types/node" "*" +"@types/humanize-duration@^3.18.1": + version "3.18.1" + resolved "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.18.1.tgz#10090d596053703e7de0ac43a37b96cd9fc78309" + integrity sha512-MUgbY3CF7hg/a/jogixmAufLjJBQT7WEf8Q+kYJkOc47ytngg1IuZobCngdTjAgY83JWEogippge5O5fplaQlw== + "@types/inquirer@^7.3.1": version "7.3.1" resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d" @@ -5401,9 +6061,9 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-reports@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a" - integrity sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA== + 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" "*" @@ -5415,15 +6075,14 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@*", "@types/jest@^26.0.7": - version "26.0.14" - resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.14.tgz#078695f8f65cb55c5a98450d65083b2b73e5a3f3" - integrity sha512-Hz5q8Vu0D288x3iWXePSn53W7hAjP0H7EQ6QvDO9c7t46mR0lNOLlfuwQ+JkVxuhygHzlzPX+0jKdA3ZgSh+Vg== +"@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: - jest-diff "^25.2.1" - pretty-format "^25.2.1" + "@types/jest" "*" -"@types/jest@26.x": +"@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" integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== @@ -5432,9 +6091,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" "*" @@ -5470,7 +6129,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== @@ -5489,7 +6148,7 @@ resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== -"@types/keyv@*", "@types/keyv@^3.1.1": +"@types/keyv@*": version "3.1.1" resolved "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== @@ -5533,6 +6192,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.26.0" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36" + integrity sha512-zYmLYGczqBaOFaFjR1giG1QCbGMlXWOJcYgH9Mnk0MGcZHq1aer3ZwGXX8vd9NOfai6mAI/mVU6jD9hK0Wys7Q== + "@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" @@ -5554,19 +6223,24 @@ dependencies: "@types/braces" "*" +"@types/mime-types@^2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73" + integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM= + "@types/mime@*": version "2.0.1" 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" "*" -"@types/minimatch@*": +"@types/minimatch@*", "@types/minimatch@^3.0.3": version "3.0.3" resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== @@ -5604,14 +6278,7 @@ 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": +"@types/node-fetch@2.5.7": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== @@ -5619,6 +6286,14 @@ "@types/node" "*" form-data "^3.0.0" +"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.4", "@types/node-fetch@^2.5.7": + 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" @@ -5639,13 +6314,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.11": - version "0.26.11" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.11.tgz#0cbc5e929f23e5ffc536920e3a887e0b3f46d1a4" - integrity sha512-BGrY9F8lBtfU+Ne1Pjb9k/PUsfSCUAqPXgKkTkM6mc5213H5VAoM12zG/sz/cr3jI3AXcngNbAYWlqSrXsWJug== - dependencies: - "@types/node" "*" - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -5663,20 +6331,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" @@ -5729,19 +6383,32 @@ "@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== +"@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.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" "*" + +"@types/pluralize@^0.0.29": + version "0.0.29" + resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c" + integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA== "@types/prettier@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4" integrity sha512-/rM+sWiuOZ5dvuVzV37sUuklsbg+JPOP8d+nNFlo2ZtfpzPiPvh1/gc8liWOLBqe+sR+ZM7guPaIcTt6UZTo7Q== -"@types/prop-types@*": +"@types/prop-types@*", "@types/prop-types@^15.7.3": version "15.7.3" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== @@ -5774,6 +6441,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" @@ -5836,6 +6510,13 @@ dependencies: "@types/react" "*" +"@types/react-text-truncate@^0.14.0": + version "0.14.0" + resolved "https://registry.npmjs.org/@types/react-text-truncate/-/react-text-truncate-0.14.0.tgz#588bbabbc7f2a13815e805f3a48942db73fe65fe" + integrity sha512-XZNmx8mMPFjRLFjFqIF5uLPXEZ4THKxoEv1yU63JzInV3ES42PD+DaQOK6+Rd+cRolzrNoY4YIY9rrW8kh4ikw== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.2.0": version "4.2.4" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.4.tgz#c7416225987ccdb719262766c1483da8f826838d" @@ -5966,6 +6647,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" @@ -6027,9 +6713,9 @@ integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ== "@types/tar@^4.0.3": - version "4.0.3" - resolved "https://registry.npmjs.org/@types/tar/-/tar-4.0.3.tgz#e2cce0b8ff4f285293243f5971bd7199176ac489" - integrity sha512-Z7AVMMlkI8NTWF0qGhC4QIX0zkV/+y0J8x7b/RsHrN0310+YNjoJd8UrApCiGBCWtKjxS9QhNqLi2UJNToh5hA== + version "4.0.4" + resolved "https://registry.npmjs.org/@types/tar/-/tar-4.0.4.tgz#d680de60855e7778a51c672b755869a3b8d2889f" + integrity sha512-0Xv+xcmkTsOZdIF4yCnd7RkOOyfyqPaqJ7RZFKnwdxfDbkN3eAAE9sHl8zJFqBz4VhxolW9EErbjR1oyH7jK2A== dependencies: "@types/minipass" "*" "@types/node" "*" @@ -6042,9 +6728,9 @@ "@types/estree" "*" "@types/testing-library__jest-dom@^5.9.1": - version "5.9.1" - resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5" - integrity sha512-yYn5EKHO3MPEMSOrcAb1dLWY+68CG29LiXKsWmmpVHqoP5+ZRiAVLyUHvPNrO2dABDdUGZvavMsaGpWNjM6N2g== + version "5.9.5" + resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0" + integrity sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ== dependencies: "@types/jest" "*" @@ -6074,6 +6760,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" @@ -6099,9 +6792,9 @@ "@types/node" "*" "@types/uuid@^8.0.0": - version "8.0.0" - resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" - integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw== + version "8.3.0" + resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== "@types/webpack-dev-server@*", "@types/webpack-dev-server@^3.11.0": version "3.11.0" @@ -6114,10 +6807,10 @@ "@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.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== "@types/webpack-node-externals@^2.5.0": version "2.5.0" @@ -6136,9 +6829,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" "*" @@ -6173,18 +6866,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.8" + resolved "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a" + integrity sha512-EyvT83ezOdec7BhDaEcsklWy7RSIdi6CNe95tmOAK0yx/Lm30C9K75snT3fYayK59ApC2oyW+rcHErdG05FHJA== 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" @@ -6203,95 +6903,79 @@ 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.15.2": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.2.tgz#981b26b4076c62a5a55873fbef3fe98f83360c61" + integrity sha512-uiQQeu9tWl3f1+oK0yoAv9lt/KXO24iafxgQTkIYO/kitruILGx3uH+QtIAHqxFV+yIsdnJH+alel9KuE3J15Q== dependencies: - "@typescript-eslint/experimental-utils" "3.10.1" + "@typescript-eslint/experimental-utils" "4.15.2" + "@typescript-eslint/scope-manager" "4.15.2" 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.15.2", "@typescript-eslint/experimental-utils@^4.0.1": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.2.tgz#5efd12355bd5b535e1831282e6cf465b9a71cf36" + integrity sha512-Fxoshw8+R5X3/Vmqwsjc8nRO/7iTysRtDqx6rlfLZ7HbT8TZhPeQqbPjTyk2RheH3L8afumecTQnUc9EeXxohQ== 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.15.2" + "@typescript-eslint/types" "4.15.2" + "@typescript-eslint/typescript-estree" "4.15.2" 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/scope-manager@4.15.2": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.15.2.tgz#5725bda656995960ae1d004bfd1cd70320f37f4f" + integrity sha512-Zm0tf/MSKuX6aeJmuXexgdVyxT9/oJJhaCkijv0DvJVT3ui4zY6XYd6iwIo/8GEZGy43cd7w1rFMiCLHbRzAPQ== + dependencies: + "@typescript-eslint/types" "4.15.2" + "@typescript-eslint/visitor-keys" "4.15.2" + +"@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/types@4.15.2": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.2.tgz#04acf3a2dc8001a88985291744241e732ef22c60" + integrity sha512-r7lW7HFkAarfUylJ2tKndyO9njwSyoy6cpfDKWPX6/ctZA+QyaYscAHXVAfJqtnY6aaTwDYrOhp+ginlbc7HfQ== + +"@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" @@ -6299,19 +6983,33 @@ 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/typescript-estree@4.15.2": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.2.tgz#c2f7a1e94f3428d229d5ecff3ead6581ee9b62fa" + integrity sha512-cGR8C2g5SPtHTQvAymEODeqx90pJHadWsgTtx6GbnTWKqsg7yp6Eaya9nFzUd4KrKhxdYTTFBiYeTPQaz/l8bw== dependencies: - eslint-visitor-keys "^1.1.0" + "@typescript-eslint/types" "4.15.2" + "@typescript-eslint/visitor-keys" "4.15.2" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + semver "^7.3.2" + tsutils "^3.17.1" -"@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== +"@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: - "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/types" "4.14.0" + eslint-visitor-keys "^2.0.0" + +"@typescript-eslint/visitor-keys@4.15.2": + version "4.15.2" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.2.tgz#3d1c7979ce75bf6acf9691109bd0d6b5706192b9" + integrity sha512-TME1VgSb7wTwgENN5KVj4Nqg25hP8DisXxNBojM4Nn31rYaNDIocNm5cmjOFfh42n7NVERxWrDFoETO/76ePyg== + dependencies: + "@typescript-eslint/types" "4.15.2" eslint-visitor-keys "^2.0.0" "@webassemblyjs/ast@1.9.0": @@ -6459,18 +7157,6 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" -"@webpack-contrib/schema-utils@^1.0.0-beta.0": - version "1.0.0-beta.0" - resolved "https://registry.npmjs.org/@webpack-contrib/schema-utils/-/schema-utils-1.0.0-beta.0.tgz#bf9638c9464d177b48209e84209e23bee2eb4f65" - integrity sha512-LonryJP+FxQQHsjGBi6W786TQB1Oym+agTpY0c+Kj8alnIw+DLUJb6SI8Y1GHGhLCH1yPRrucjObUmxNICQ1pg== - dependencies: - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chalk "^2.3.2" - strip-ansi "^4.0.0" - text-table "^0.2.0" - webpack-log "^1.1.2" - "@wry/equality@^0.1.2": version "0.1.11" resolved "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" @@ -6498,16 +7184,7 @@ resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -"@zkochan/cmd-shim@^3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz#2ab8ed81f5bb5452a85f25758eb9b8681982fd2e" - integrity sha512-o8l0+x7C7sMZU3v9GuJIAU10qQLtwR1dtRQIOmlNMtyaqhmpXOzx1HWiYoWfmmf9HHZoAkXpc9TM9PQYF9d4Jg== - dependencies: - is-windows "^1.0.0" - mkdirp-promise "^5.0.1" - mz "^2.5.0" - -JSONStream@^1.0.4, JSONStream@^1.3.4: +JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== @@ -6525,6 +7202,13 @@ abbrev@1: resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + abstract-logging@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.0.tgz#08a85814946c98ef06f4256ad470aba1886d4490" @@ -6589,18 +7273,16 @@ acorn@^7.4.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +add-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" + integrity sha1-anmQQ3ynNtXhKI25K9MmbV9csqo= + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== -agent-base@4, agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agent-base@6: version "6.0.1" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.1.tgz#808007e4e5867decb0ab6ab2f928fbdb5a596db4" @@ -6608,18 +7290,13 @@ agent-base@6: dependencies: debug "4" -agent-base@~4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" - integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== - dependencies: - es6-promisify "^5.0.0" - -agentkeepalive@^3.4.1: - version "3.5.2" - resolved "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.2.tgz#a113924dd3fa24a0bc3b78108c450c2abee00f67" - integrity sha512-e0L/HNe6qkQ7H19kTlRRqUibEAwDK5AFk6y3PtMsuut2VAH6+Q4xZml1tNDJD7kSAyqmbG/K08K5WEJYtUrSlQ== +agentkeepalive@^4.1.3: + version "4.1.4" + resolved "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.1.4.tgz#d928028a4862cb11718e55227872e842a44c945b" + integrity sha512-+V/rGa3EuU74H6wR04plBb7Ks10FbtUQgRj/FQOG7uUIEuaINI+AiqJR1k6t3SVNs7o7ZjIdus6706qqzVq8jQ== dependencies: + debug "^4.1.0" + depd "^1.1.2" humanize-ms "^1.2.1" aggregate-error@3.0.1, aggregate-error@^3.0.0: @@ -6673,6 +7350,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" @@ -6702,7 +7389,7 @@ ansi-colors@^4.1.1: resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== -ansi-escapes@^3.0.0, ansi-escapes@^3.2.0: +ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== @@ -6714,7 +7401,7 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0, ansi-escapes@^4.3.1: dependencies: type-fest "^0.11.0" -ansi-html@0.0.7: +ansi-html@0.0.7, ansi-html@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= @@ -6729,7 +7416,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== @@ -6792,43 +7479,21 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-cache-control@^0.11.1: - version "0.11.1" - resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.1.tgz#3bce0924ae7322a8b9f7ca1e2fb036d1fc9f1df5" - integrity sha512-6iHa8TkcKt4rx5SKRzDNjUIpCQX+7/FlZwD7vRh9JDnM4VH8SWhpj8fUR3CiEY8Kuc4ChXnOY8bCcMju5KPnIQ== +apollo-cache-control@^0.11.6: + version "0.11.6" + resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.6.tgz#f7bdf924272af47ac474cf3f3f35cfc038cc9485" + integrity sha512-YZ+uuIG+fPy+mkpBS2qKF0v1qlzZ3PW6xZVaDukeK3ed3iAs4L/2YnkTqau3OmoF/VPzX2FmSkocX/OVd59YSw== dependencies: - apollo-server-env "^2.4.5" - apollo-server-plugin-base "^0.9.1" + apollo-server-env "^3.0.0" + apollo-server-plugin-base "^0.10.4" -apollo-datasource@^0.7.2: - version "0.7.2" - resolved "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.2.tgz#1662ee93453a9b89af6f73ce561bde46b41ebf31" - integrity sha512-ibnW+s4BMp4K2AgzLEtvzkjg7dJgCaw9M5b5N0YKNmeRZRnl/I/qBTQae648FsRKgMwTbRQIvBhQ0URUFAqFOw== +apollo-datasource@^0.7.3: + version "0.7.3" + resolved "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.3.tgz#c824eb1457bdee5a3173ced0e35e594547e687a0" + integrity sha512-PE0ucdZYjHjUyXrFWRwT02yLcx2DACsZ0jm1Mp/0m/I9nZu/fEkvJxfsryXB6JndpmQO77gQHixf/xGCN976kA== dependencies: - apollo-server-caching "^0.5.2" - apollo-server-env "^2.4.5" - -apollo-engine-reporting-protobuf@^0.5.2: - version "0.5.2" - resolved "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz#b01812508a1c583328a8dc603769bc63b8895e7e" - integrity sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg== - dependencies: - "@apollo/protobufjs" "^1.0.3" - -apollo-engine-reporting@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.3.0.tgz#3bb59f81aaf6b967ed098896a4a60a053b4eed5a" - integrity sha512-SbcPLFuUZcRqDEZ6mSs8uHM9Ftr8yyt2IEu0JA8c3LNBmYXSLM7MHqFe80SVcosYSTBgtMz8mLJO8orhYoSYZw== - dependencies: - apollo-engine-reporting-protobuf "^0.5.2" - apollo-graphql "^0.5.0" - apollo-server-caching "^0.5.2" - apollo-server-env "^2.4.5" - apollo-server-errors "^2.4.2" - apollo-server-plugin-base "^0.9.1" - apollo-server-types "^0.5.1" - async-retry "^1.2.1" - uuid "^8.0.0" + apollo-server-caching "^0.5.3" + apollo-server-env "^3.0.0" apollo-env@^0.6.5: version "0.6.5" @@ -6840,12 +7505,22 @@ apollo-env@^0.6.5: node-fetch "^2.2.0" sha.js "^2.4.11" -apollo-graphql@^0.5.0: - version "0.5.0" - resolved "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.5.0.tgz#7e9152093211b58352aa6504d8d39ec7241d6872" - integrity sha512-YSdF/BKPbsnQpxWpmCE53pBJX44aaoif31Y22I/qKpB6ZSGzYijV5YBoCL5Q15H2oA/v/02Oazh9lbp4ek3eig== +apollo-env@^0.6.6: + version "0.6.6" + resolved "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.6.tgz#d7880805c4e96ee3d4142900a405176a04779438" + integrity sha512-hXI9PjJtzmD34XviBU+4sPMOxnifYrHVmxpjykqI/dUD2G3yTiuRaiQqwRwB2RCdwC1Ug/jBfoQ/NHDTnnjndQ== dependencies: - apollo-env "^0.6.5" + "@types/node-fetch" "2.5.7" + core-js "^3.0.1" + node-fetch "^2.2.0" + sha.js "^2.4.11" + +apollo-graphql@^0.6.0: + version "0.6.1" + resolved "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.6.1.tgz#d0bf0aff76f445de3da10e08f6974f1bf65f5753" + integrity sha512-ZRXAV+k+hboCVS+FW86FW/QgnDR7gm/xMUwJPGXEbV53OLGuQQdIT0NCYK7AzzVkCfsbb7NJ3mmEclkZY9uuxQ== + dependencies: + apollo-env "^0.6.6" lodash.sortby "^4.7.0" apollo-link-http-common@^0.2.14: @@ -6867,6 +7542,13 @@ apollo-link@^1.2.12, apollo-link@^1.2.14: tslib "^1.9.3" zen-observable-ts "^0.8.21" +apollo-reporting-protobuf@^0.6.2: + version "0.6.2" + resolved "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.6.2.tgz#5572866be9b77f133916532b10e15fbaa4158304" + integrity sha512-WJTJxLM+MRHNUxt1RTl4zD0HrLdH44F2mDzMweBj1yHL0kSt8I1WwoiF/wiGVSpnG48LZrBegCaOJeuVbJTbtw== + dependencies: + "@apollo/protobufjs" "^1.0.3" + apollo-server-caching@0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.1.tgz#5cd0536ad5473abb667cc82b59bc56b96fb35db6" @@ -6874,45 +7556,48 @@ apollo-server-caching@0.5.1: dependencies: lru-cache "^5.0.0" -apollo-server-caching@^0.5.2: - version "0.5.2" - resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz#bef5d5e0d48473a454927a66b7bb947a0b6eb13e" - integrity sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ== +apollo-server-caching@^0.5.3: + version "0.5.3" + resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.3.tgz#cf42a77ad09a46290a246810075eaa029b5305e1" + integrity sha512-iMi3087iphDAI0U2iSBE9qtx9kQoMMEWr6w+LwXruBD95ek9DWyj7OeC2U/ngLjRsXM43DoBDXlu7R+uMjahrQ== dependencies: - lru-cache "^5.0.0" + lru-cache "^6.0.0" -apollo-server-core@^2.16.1: - version "2.16.1" - resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.16.1.tgz#5b5b8245ab9c0cb6c2367ec19ab855dea6ccea3a" - integrity sha512-nuwn5ZBbmzPwDetb3FgiFFJlNK7ZBFg8kis/raymrjd3eBGdNcOyMTJDl6J9673X9Xqp+dXQmFYDW/G3G8S1YA== +apollo-server-core@^2.16.1, apollo-server-core@^2.21.0: + version "2.21.0" + resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.21.0.tgz#12ee11aee61fa124f11b1d73cae2e068112a3a53" + integrity sha512-GtIiq2F0dVDLzzIuO5+dK/pGq/sGxYlKCqAuQQqzYg0fvZ7fukyluXtcTe0tMI+FJZjU0j0WnKgiLsboCoAlPQ== dependencies: "@apollographql/apollo-tools" "^0.4.3" "@apollographql/graphql-playground-html" "1.6.26" - "@types/graphql-upload" "^8.0.0" + "@apollographql/graphql-upload-8-fork" "^8.1.3" "@types/ws" "^7.0.0" - apollo-cache-control "^0.11.1" - apollo-datasource "^0.7.2" - apollo-engine-reporting "^2.3.0" - apollo-server-caching "^0.5.2" - apollo-server-env "^2.4.5" + apollo-cache-control "^0.11.6" + apollo-datasource "^0.7.3" + apollo-graphql "^0.6.0" + apollo-reporting-protobuf "^0.6.2" + apollo-server-caching "^0.5.3" + apollo-server-env "^3.0.0" apollo-server-errors "^2.4.2" - apollo-server-plugin-base "^0.9.1" - apollo-server-types "^0.5.1" - apollo-tracing "^0.11.1" + apollo-server-plugin-base "^0.10.4" + apollo-server-types "^0.6.3" + apollo-tracing "^0.12.2" + async-retry "^1.2.1" fast-json-stable-stringify "^2.0.0" - graphql-extensions "^0.12.4" - graphql-tag "^2.9.2" - graphql-tools "^4.0.0" - graphql-upload "^8.0.2" + graphql-extensions "^0.12.8" + graphql-tag "^2.11.0" + graphql-tools "^4.0.8" loglevel "^1.6.7" + lru-cache "^6.0.0" sha.js "^2.4.11" subscriptions-transport-ws "^0.9.11" + uuid "^8.0.0" ws "^6.0.0" -apollo-server-env@^2.4.5: - version "2.4.5" - resolved "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz#73730b4f0439094a2272a9d0caa4079d4b661d5f" - integrity sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA== +apollo-server-env@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-3.0.0.tgz#0157c51f52b63aee39af190760acf789ffc744d9" + integrity sha512-tPSN+VttnPsoQAl/SBVUpGbLA97MXG990XIwq6YUnJyAixrrsjW1xYG7RlaOqetxm80y5mBZKLrRDiiSsW/vog== dependencies: node-fetch "^2.1.2" util.promisify "^1.0.0" @@ -6923,42 +7608,43 @@ apollo-server-errors@^2.4.2: integrity sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ== apollo-server-express@^2.16.1: - version "2.16.1" - resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.16.1.tgz#7438bca590ef8577d24d20ba0b6d582c120c0146" - integrity sha512-Oq5YNcaMYnRk6jDmA9LWf8oSd2KHDVe7jQ4wtooAvG9FVUD+FaFBgSkytXHMvtifQh2wdF07Ri8uDLMz6IQjTw== + version "2.21.0" + resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.21.0.tgz#29bd4ec728e1992da240c5956c3ce6d95c1d252e" + integrity sha512-zbOSNGuxUjlOFZnRrbMpga3pKDEroitF4NAqoVxgBivx7v2hGsE7rljct3PucTx2cMN90AyYe3cU4oA8jBxZIQ== dependencies: "@apollographql/graphql-playground-html" "1.6.26" "@types/accepts" "^1.3.5" "@types/body-parser" "1.19.0" - "@types/cors" "^2.8.4" + "@types/cors" "2.8.8" "@types/express" "4.17.7" + "@types/express-serve-static-core" "4.17.18" accepts "^1.3.5" - apollo-server-core "^2.16.1" - apollo-server-types "^0.5.1" + apollo-server-core "^2.21.0" + apollo-server-types "^0.6.3" body-parser "^1.18.3" cors "^2.8.4" express "^4.17.1" graphql-subscriptions "^1.0.0" - graphql-tools "^4.0.0" + graphql-tools "^4.0.8" parseurl "^1.3.2" subscriptions-transport-ws "^0.9.16" type-is "^1.6.16" -apollo-server-plugin-base@^0.9.1: - version "0.9.1" - resolved "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.9.1.tgz#a62ae9ab4e89790fd4cc5d123bb616da34e8e5fb" - integrity sha512-kvrX4Z3FdpjrZdHkyl5iY2A1Wvp4b6KQp00DeZqss7GyyKNUBKr80/7RQgBLEw7EWM7WB19j459xM/TjvW0FKQ== +apollo-server-plugin-base@^0.10.4: + version "0.10.4" + resolved "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.10.4.tgz#fbf73f64f95537ca9f9639dd7c535eb5eeb95dcd" + integrity sha512-HRhbyHgHFTLP0ImubQObYhSgpmVH4Rk1BinnceZmwudIVLKrqayIVOELdyext/QnSmmzg5W7vF3NLGBcVGMqDg== dependencies: - apollo-server-types "^0.5.1" + apollo-server-types "^0.6.3" -apollo-server-types@^0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz#091c09652894d6532db9ba873574443adabf85b9" - integrity sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA== +apollo-server-types@^0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.6.3.tgz#f7aa25ff7157863264d01a77d7934aa6e13399e8" + integrity sha512-aVR7SlSGGY41E1f11YYz5bvwA89uGmkVUtzMiklDhZ7IgRJhysT5Dflt5IuwDxp+NdQkIhVCErUXakopocFLAg== dependencies: - apollo-engine-reporting-protobuf "^0.5.2" - apollo-server-caching "^0.5.2" - apollo-server-env "^2.4.5" + apollo-reporting-protobuf "^0.6.2" + apollo-server-caching "^0.5.3" + apollo-server-env "^3.0.0" apollo-server@^2.16.1: version "2.16.1" @@ -6971,13 +7657,13 @@ apollo-server@^2.16.1: graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@^0.11.1: - version "0.11.2" - resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.2.tgz#14308b176e021f5e6ec3ee670f8f96e9fbfdb50c" - integrity sha512-QjmRd2ozGD+PfmF6U9w/w6jrclYSBNczN6Bzppr8qA5somEGl5pqdprIZYL28H0IapZiutA3x6p6ZVF/cVX8wA== +apollo-tracing@^0.12.2: + version "0.12.2" + resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.12.2.tgz#a261c3970bb421b6dadf50cd85d75b2567a7e52c" + integrity sha512-SYN4o0C0wR1fyS3+P0FthyvsQVHFopdmN3IU64IaspR/RZScPxZ3Ae8uu++fTvkQflAkglnFM0aX6DkZERBp6w== dependencies: - apollo-server-env "^2.4.5" - apollo-server-plugin-base "^0.9.1" + apollo-server-env "^3.0.0" + apollo-server-plugin-base "^0.10.4" apollo-upload-client@^13.0.0: version "13.0.0" @@ -7036,9 +7722,9 @@ archiver-utils@^2.1.0: readable-stream "^2.0.0" archiver@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/archiver/-/archiver-5.0.2.tgz#b2c435823499b1f46eb07aa18e7bcb332f6ca3fc" - integrity sha512-Tq3yV/T4wxBsD2Wign8W9VQKhaUxzzRmjEiSoOK0SLqPgDP/N1TKdYyBeIEu56T4I9iO4fKTTR0mN9NWkBA0sg== + version "5.2.0" + resolved "https://registry.npmjs.org/archiver/-/archiver-5.2.0.tgz#25aa1b3d9febf7aec5b0f296e77e69960c26db94" + integrity sha512-QEAKlgQuAtUxKeZB9w5/ggKXh21bZS+dzzuQ0RPBC20qtDCbTyzqmisoeJP46MP39fg4B4IcyvR+yeyEBdblsQ== dependencies: archiver-utils "^2.1.0" async "^3.2.0" @@ -7046,7 +7732,7 @@ archiver@^5.0.2: readable-stream "^3.6.0" readdir-glob "^1.0.0" tar-stream "^2.1.4" - zip-stream "^4.0.0" + zip-stream "^4.0.4" are-we-there-yet@~1.1.2: version "1.1.5" @@ -7068,6 +7754,11 @@ argparse@^1.0.10, argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + aria-query@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" @@ -7099,10 +7790,10 @@ arr-union@^3.1.0: resolved "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= -array-differ@^2.0.3: - version "2.1.0" - resolved "https://registry.npmjs.org/array-differ/-/array-differ-2.1.0.tgz#4b9c1c3f14b906757082925769e8ab904f4801b1" - integrity sha512-KbUpJgx909ZscOc/7CLATBFam7P1Z1QRQInvgT0UztM9Q72aGKCunKASAl7WNW0tnPmPyEMeMhdsfWhfmW037w== +array-differ@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b" + integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg== array-each@^1.0.1: version "1.0.1" @@ -7212,6 +7903,11 @@ arrify@^1.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= +arrify@^2.0.0, arrify@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + asap@^2.0.0, asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -7256,16 +7952,18 @@ ast-types-flow@0.0.7, ast-types-flow@^0.0.7: resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= -ast-types@0.11.3: - version "0.11.3" - resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.11.3.tgz#c20757fe72ee71278ea0ff3d87e5c2ca30d9edf8" - integrity sha512-XA5o5dsNw8MhyW0Q7MWXJWc4oOzZKbdsEJq45h7c8q/d9DwWZ5F2ugUc1PuMLPGsUnphCt/cNDHu8JeBbxf1qA== - ast-types@^0.13.2: version "0.13.2" resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48" integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA== +ast-types@^0.14.2: + version "0.14.2" + resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd" + integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA== + dependencies: + tslib "^2.0.1" + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -7286,6 +7984,11 @@ async-limiter@~1.0.0: resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-lock@^1.1.0: + version "1.2.4" + resolved "https://registry.npmjs.org/async-lock/-/async-lock-1.2.4.tgz#80d0d612383045dd0c30eb5aad08510c1397cb91" + integrity sha512-UBQJC2pbeyGutIfYmErGc9RaJYnpZ1FHaxuKwb0ahvGiiCkPUf3p67Io+YLPmmv3RHY+mF6JEtNW8FlHsraAaA== + async-retry@^1.2.1: version "1.3.1" resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz#139f31f8ddce50c0870b0ba558a6079684aaed55" @@ -7320,11 +8023,6 @@ at-least-node@^1.0.0: resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -atob-lite@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696" - integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY= - atob@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" @@ -7355,11 +8053,31 @@ autoprefixer@^9.7.2: postcss "^7.0.32" postcss-value-parser "^4.1.0" +aws-sdk@^2.840.0: + version "2.840.0" + resolved "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.840.0.tgz#f5529c9bd3bf0be7f8e855a23ff9c12b1705418f" + integrity sha512-ngesHJqb0PXYjJNnCsAX4yLkR6JFQJB+3eDGwh3mYRjcq9voix5RfbCFQT1lwWu7bcMBPCrRIA2lJkkTMYXq+A== + 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" @@ -7379,18 +8097,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: @@ -7437,16 +8162,16 @@ babel-helper-to-multiple-sequence-expressions@^0.5.0: resolved "https://registry.npmjs.org/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d" integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA== -babel-jest@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.5.2.tgz#164f367a35946c6cf54eaccde8762dec50422250" - integrity sha512-U3KvymF3SczA3vOL/cgiUFOznfMET+XDIXiWnoJV45siAp2pLMG8i2+/MGZlAC3f/F6Q40LR4M4qDrWZ9wkK8A== +babel-jest@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" + integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== dependencies: - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^26.5.0" + babel-preset-jest "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" @@ -7501,10 +8226,10 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.5.0.tgz#3916b3a28129c29528de91e5784a44680db46385" - integrity sha512-ck17uZFD3CDfuwCLATWZxkkuGGFhMij8quP8CNhwj8ek1mqFgbFzRJ30xwC04LLscj/aKsVFfRST+b5PT7rSuw== +babel-plugin-jest-hoist@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" + integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" @@ -7601,14 +8326,14 @@ babel-plugin-named-asset-import@^0.3.1: resolved "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.6.tgz#c9750a1b38d85112c9e166bf3ef7c5dbc605f4be" integrity sha512-1aGDUfL1qOOIoqk9QKGIo2lANk+C7ko/fqH0uIyC71x3PEGz0uVP8ISgfEsFuG+FKmjHTvFK/nNM8dowpmUxLA== -babel-plugin-react-docgen@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/babel-plugin-react-docgen/-/babel-plugin-react-docgen-4.1.0.tgz#1dfa447dac9ca32d625a123df5733a9e47287c26" - integrity sha512-vzpnBlfGv8XOhJM2zbPyyqw2OLEbelgZZsaaRRTpVwNKuYuc+pUg4+dy7i9gCRms0uOQn4osX571HRcCJMJCmA== +babel-plugin-react-docgen@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/babel-plugin-react-docgen/-/babel-plugin-react-docgen-4.2.1.tgz#7cc8e2f94e8dc057a06e953162f0810e4e72257b" + integrity sha512-UQ0NmGHj/HAqi5Bew8WvNfCk8wSsmdgNd8ZdMjBCICtyCJCq9LiqgqvjCYe570/Wg7AQArSq1VQ60Dd/CHN7mQ== dependencies: + ast-types "^0.14.2" lodash "^4.17.15" react-docgen "^5.0.0" - recast "^0.14.7" babel-plugin-syntax-jsx@^6.18.0: version "6.18.0" @@ -7679,10 +8404,10 @@ babel-plugin-transform-undefined-to-void@^6.9.4: resolved "https://registry.npmjs.org/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280" integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA= -babel-preset-current-node-syntax@^0.1.3: - version "0.1.3" - resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.3.tgz#b4b547acddbf963cba555ba9f9cbbb70bfd044da" - integrity sha512-uyexu1sVwcdFnyq9o8UQYsXwXflIh8LvrF5+cKrYam93ned1CStffB3+BEcsxGSgagoA3GEyjDqO4a/58hyPYQ== +babel-preset-current-node-syntax@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.0.tgz#cf5feef29551253471cfa82fc8e0f5063df07a77" + integrity sha512-mGkvkpocWJes1CmMKtgGUwCeeq0pOhALyymozzDWYomHTbDLwueDYG6p4TK1YOeYHCzBzYPsWkgTto10JubI1Q== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" @@ -7695,6 +8420,7 @@ babel-preset-current-node-syntax@^0.1.3: "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" babel-preset-fbjs@^3.3.0: version "3.3.0" @@ -7729,13 +8455,13 @@ babel-preset-fbjs@^3.3.0: "@babel/plugin-transform-template-literals" "^7.0.0" babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" -babel-preset-jest@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.5.0.tgz#f1b166045cd21437d1188d29f7fba470d5bdb0e7" - integrity sha512-F2vTluljhqkiGSJGBg/jOruA8vIIIL11YrxRcO7nviNTMbbofPSHwnm8mgP7d/wS7wRSexRoI6X1A6T74d4LQA== +babel-preset-jest@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" + integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== dependencies: - babel-plugin-jest-hoist "^26.5.0" - babel-preset-current-node-syntax "^0.1.3" + babel-plugin-jest-hoist "^26.6.2" + babel-preset-current-node-syntax "^1.0.0" "babel-preset-minify@^0.5.0 || 0.6.0-alpha.5": version "0.5.1" @@ -7806,12 +8532,7 @@ base64-arraybuffer@^0.2.0: resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45" integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ== -base64-js@^1.0.2, base64-js@^1.2.0: - 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: +base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.0, 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== @@ -7858,7 +8579,7 @@ bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: dependencies: tweetnacl "^0.14.3" -before-after-hook@^2.0.0, before-after-hook@^2.1.0: +before-after-hook@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== @@ -7897,6 +8618,11 @@ big.js@^5.2.2: resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@^9.0.0: + version "9.0.1" + resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" + integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -7922,20 +8648,7 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bintrees@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" - integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= - -bl@^1.0.0: - version "1.2.2" - resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" - integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - -bl@^4.0.1, bl@^4.0.3: +bl@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== @@ -7951,7 +8664,7 @@ block-stream@*: dependencies: inherits "~2.0.0" -bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2: +bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -8148,7 +8861,7 @@ browserslist@4.10.0: node-releases "^1.1.52" pkg-up "^3.1.0" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.3: +browserslist@^4.0.0, browserslist@^4.12.0: version "4.13.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz#42556cba011e1b0a2775b611cba6a8eca18e940d" integrity sha512-MINatJ5ZNrLnQ6blGvePd/QOz9Xtu+Ne+x29iQSCHfkU5BugKVJwZKn/iiL8UbpIpa3JhviKjz+XxMo0m2caFQ== @@ -8158,6 +8871,17 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.3: escalade "^3.0.1" node-releases "^1.1.58" +browserslist@^4.14.5, browserslist@^4.15.0: + version "4.15.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.15.0.tgz#3d48bbca6a3f378e86102ffd017d9a03f122bdb0" + integrity sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ== + dependencies: + caniuse-lite "^1.0.30001164" + colorette "^1.2.1" + electron-to-chromium "^1.3.612" + escalade "^3.1.1" + node-releases "^1.1.67" + bs-logger@0.x: version "0.2.6" resolved "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" @@ -8172,29 +8896,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -btoa-lite@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" - integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc= - btoa@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g== -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -8205,11 +8911,6 @@ buffer-equal-constant-time@1.0.1: resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= - buffer-from@1.x, buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -8240,7 +8941,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.3.0: +buffer@4.9.2, 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== @@ -8249,14 +8950,6 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.1.0: - version "5.7.1" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - buffer@^5.5.0: version "5.6.0" resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" @@ -8300,6 +8993,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" @@ -8312,10 +9015,10 @@ byline@^5.0.0: resolved "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" integrity sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE= -byte-size@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/byte-size/-/byte-size-5.0.1.tgz#4b651039a5ecd96767e71a3d7ed380e48bed4191" - integrity sha512-/XuKeqWocKsYa/cBY1YbSJSWWqTi4cFgr9S6OyM7PBaPbr9zvNGwWP33vt0uqGhwDdN+y3yhbXVILEUpnwEWGw== +byte-size@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/byte-size/-/byte-size-7.0.0.tgz#36528cd1ca87d39bd9abd51f5715dc93b6ceb032" + integrity sha512-NNiBxKgxybMBtWdmvx7ZITJi4ZG+CYUgwOSZTfqB1qogkRHrhbQE/R2r5Fh94X+InN5MCYz6SvB/ejHMj/HbsQ== bytes@3.0.0: version "3.0.0" @@ -8327,7 +9030,7 @@ bytes@3.1.0: resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== -cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: +cacache@^12.0.2: version "12.0.3" resolved "https://registry.npmjs.org/cacache/-/cacache-12.0.3.tgz#be99abba4e1bf5df461cd5a2c1071fc432573390" integrity sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw== @@ -8386,14 +9089,6 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-lookup@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz#87be64a18b925234875e10a9bb1ebca4adce6b38" - integrity sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg== - dependencies: - "@types/keyv" "^3.1.1" - keyv "^4.0.0" - cacheable-lookup@^5.0.3: version "5.0.3" resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" @@ -8475,6 +9170,14 @@ camel-case@4.1.1, camel-case@^4.1.1: pascal-case "^3.1.1" tslib "^1.10.0" +camel-case@4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" + integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== + dependencies: + pascal-case "^3.1.2" + tslib "^2.0.3" + camelcase-keys@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" @@ -8483,15 +9186,6 @@ camelcase-keys@^2.0.0: camelcase "^2.0.0" map-obj "^1.0.0" -camelcase-keys@^4.0.0: - version "4.2.0" - resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77" - integrity sha1-oqpfsa9oh1glnDLBQUJteJI7m3c= - dependencies: - camelcase "^4.1.0" - map-obj "^2.0.0" - quick-lru "^1.0.0" - camelcase-keys@^6.2.2: version "6.2.2" resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" @@ -8511,7 +9205,7 @@ camelcase@^3.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= -camelcase@^4.0.0, camelcase@^4.1.0: +camelcase@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= @@ -8526,6 +9220,11 @@ camelcase@^6.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== +camelcase@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -8541,6 +9240,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001093, can resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065" integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA== +caniuse-lite@^1.0.30001164: + version "1.0.30001165" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f" + integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA== + canvas@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/canvas/-/canvas-2.6.1.tgz#0d087dd4d60f5a5a9efa202757270abea8bef89e" @@ -8586,7 +9290,7 @@ chainsaw@~0.1.0: dependencies: traverse ">=0.3.0 <0.4" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -8691,7 +9395,7 @@ chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3. optionalDependencies: fsevents "~2.1.2" -chownr@^1.0.1, chownr@^1.1.1, chownr@^1.1.2: +chownr@^1.1.1: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== @@ -8728,6 +9432,11 @@ circleci-api@^4.0.0: dependencies: axios "^0.20.0" +cjs-module-lexer@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" + integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== + class-utils@^0.3.5: version "0.3.6" resolved "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -8750,6 +9459,11 @@ clean-css@^4.2.3: dependencies: source-map "~0.6.0" +clean-git-ref@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz#dcc0ca093b90e527e67adb5a5e55b1af6816dcd9" + integrity sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -8909,6 +9623,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" @@ -8919,6 +9638,13 @@ clsx@^1.0.1, clsx@^1.0.2, clsx@^1.0.4, clsx@^1.1.0: resolved "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +cmd-shim@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/cmd-shim/-/cmd-shim-4.1.0.tgz#b3a904a6743e9fede4148c6f3800bf2a08135bdd" + integrity sha512-lb9L7EM4I/ZRVuljLPEtUJOP+xiQVknZ4ZMpMgEp4JzNldPb27HU03hi6K1/6CoIuit/Zm/LQXySErFeXxDprw== + dependencies: + mkdirp-infer-owner "^2.0.0" + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -8943,18 +9669,18 @@ code-point-at@^1.0.0: resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -codemirror-graphql@^0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-0.12.0.tgz#55f782ffaaea7ff8dbda037e6b97b584dff2402a" - integrity sha512-C/0vKzQT5Uw+DLz6/MwKer29FQM52sh9Bem+VdDW42094j8nES1sdnuqj4k5ahNdQpW4FmVeoj/ngn2g3AWmgg== +codemirror-graphql@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-1.0.0.tgz#ba8db60dc42b87768d643b3d19bf088f43dc5380" + integrity sha512-6LnSeRldL7psIBfjDr4xXKxCqPVYfQE4Yj04p2VpIyAIpc4MVE4VOjzvILgnmAW8X93ou5/s5gQXvB4huDwTUQ== dependencies: - graphql-language-service-interface "^2.4.0" - graphql-language-service-parser "^1.6.0" + graphql-language-service-interface "^2.8.2" + graphql-language-service-parser "^1.9.0" -codemirror@^5.52.2: - version "5.53.2" - resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.53.2.tgz#9799121cf8c50809cca487304e9de3a74d33f428" - integrity sha512-wvSQKS4E+P8Fxn/AQ+tQtJnF1qH5UOlxtugFLpubEZ5jcdH2iXTVinb+Xc/4QjshuOxRm4fUsU2QPF1JJKiyXA== +codemirror@^5.54.0: + version "5.59.4" + resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.59.4.tgz#bfc11c8ce32b04818e8d661bbd790a94f4b3a6f3" + integrity sha512-achw5JBgx8QPcACDDn+EUUXmCYzx/zxEtOGXyjvLEvYY8GleUrnfm5D+Zb+UjShHggXKDT9AXrbkBZX6a0YSQg== codeowners-utils@^1.0.2: version "1.0.2" @@ -9095,10 +9821,10 @@ commander@^5.0.0, commander@^5.1.0: resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== -commander@^6.1.0: - version "6.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" - integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== +commander@^6.1.0, commander@^6.2.0: + version "6.2.1" + resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== common-tags@1.8.0, common-tags@^1.8.0: version "1.8.0" @@ -9110,13 +9836,13 @@ commondir@^1.0.1: resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= -compare-func@^1.3.1: - version "1.3.2" - resolved "https://registry.npmjs.org/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648" - integrity sha1-md0LpFfh+bxyKxLAjsM+6rMfpkg= +compare-func@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz#fb65e75edbddfd2e568554e8b5b05fff7a51fcb3" + integrity sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA== dependencies: array-ify "^1.0.0" - dot-prop "^3.0.0" + dot-prop "^5.1.0" compare-versions@^3.6.0: version "3.6.0" @@ -9128,17 +9854,17 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== -compress-commons@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.0.1.tgz#c5fa908a791a0c71329fba211d73cd2a32005ea8" - integrity sha512-xZm9o6iikekkI0GnXCmAl3LQGZj5TBDj0zLowsqi7tJtEa3FMGSEcHcqrSJIrOAk1UG/NBbDn/F1q+MG/p/EsA== +compress-commons@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.0.2.tgz#d6896be386e52f37610cef9e6fa5defc58c31bd7" + integrity sha512-qhd32a9xgzmpfoga1VQEiLEwdKZ6Plnpx5UCgIsf89FSolyJ7WnifY4Gtjgv5WR6hWAyRaHxC5MiEhU/38U70A== dependencies: buffer-crc32 "^0.2.13" - crc32-stream "^4.0.0" + crc32-stream "^4.0.1" normalize-path "^3.0.0" readable-stream "^3.6.0" -compressible@~2.0.16: +compressible@^2.0.12, compressible@~2.0.16: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== @@ -9177,6 +9903,11 @@ compute-lcm@^1.1.0: validate.io-function "^1.0.2" validate.io-integer-array "^1.0.0" +compute-scroll-into-view@^1.0.16: + version "1.0.16" + resolved "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" + integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -9224,7 +9955,7 @@ concurrently@^5.2.0: tree-kill "^1.2.2" yargs "^13.3.0" -config-chain@^1.1.11: +config-chain@^1.1.12: version "1.1.12" resolved "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" integrity sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA== @@ -9232,7 +9963,7 @@ config-chain@^1.1.11: ini "^1.3.4" proto-list "~1.2.1" -configstore@^5.0.1: +configstore@^5.0.0, configstore@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== @@ -9295,87 +10026,89 @@ content-type@~1.0.4: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -conventional-changelog-angular@^5.0.3: - version "5.0.6" - resolved "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.6.tgz#269540c624553aded809c29a3508fdc2b544c059" - integrity sha512-QDEmLa+7qdhVIv8sFZfVxU1VSyVvnXPsxq8Vam49mKUcO1Z8VTLEJk9uI21uiJUsnmm0I4Hrsdc9TgkOQo9WSA== +conventional-changelog-angular@^5.0.12: + version "5.0.12" + resolved "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.12.tgz#c979b8b921cbfe26402eb3da5bbfda02d865a2b9" + integrity sha512-5GLsbnkR/7A89RyHLvvoExbiGbd9xKdKqDTrArnPbOqBqG/2wIosu0fHwpeIRI8Tl94MhVNBXcLJZl92ZQ5USw== dependencies: - compare-func "^1.3.1" + compare-func "^2.0.0" q "^1.5.1" -conventional-changelog-core@^3.1.6: - version "3.2.3" - resolved "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-3.2.3.tgz#b31410856f431c847086a7dcb4d2ca184a7d88fb" - integrity sha512-LMMX1JlxPIq/Ez5aYAYS5CpuwbOk6QFp8O4HLAcZxe3vxoCtABkhfjetk8IYdRB9CDQGwJFLR3Dr55Za6XKgUQ== +conventional-changelog-core@^4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-4.2.2.tgz#f0897df6d53b5d63dec36b9442bd45354f8b3ce5" + integrity sha512-7pDpRUiobQDNkwHyJG7k9f6maPo9tfPzkSWbRq97GGiZqisElhnvUZSvyQH20ogfOjntB5aadvv6NNcKL1sReg== dependencies: - conventional-changelog-writer "^4.0.6" - conventional-commits-parser "^3.0.3" + add-stream "^1.0.0" + conventional-changelog-writer "^4.0.18" + conventional-commits-parser "^3.2.0" dateformat "^3.0.0" get-pkg-repo "^1.0.0" - git-raw-commits "2.0.0" + git-raw-commits "^2.0.8" git-remote-origin-url "^2.0.0" - git-semver-tags "^2.0.3" - lodash "^4.2.1" - normalize-package-data "^2.3.5" + git-semver-tags "^4.1.1" + lodash "^4.17.15" + normalize-package-data "^3.0.0" q "^1.5.1" read-pkg "^3.0.0" read-pkg-up "^3.0.0" - through2 "^3.0.0" + shelljs "^0.8.3" + through2 "^4.0.0" -conventional-changelog-preset-loader@^2.1.1: - version "2.3.0" - resolved "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-2.3.0.tgz#580fa8ab02cef22c24294d25e52d7ccd247a9a6a" - integrity sha512-/rHb32J2EJnEXeK4NpDgMaAVTFZS3o1ExmjKMtYVgIC4MQn0vkNSbYpdGRotkfGGRWiqk3Ri3FBkiZGbAfIfOQ== +conventional-changelog-preset-loader@^2.3.4: + version "2.3.4" + resolved "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-2.3.4.tgz#14a855abbffd59027fd602581f1f34d9862ea44c" + integrity sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g== -conventional-changelog-writer@^4.0.6: - version "4.0.11" - resolved "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-4.0.11.tgz#9f56d2122d20c96eb48baae0bf1deffaed1edba4" - integrity sha512-g81GQOR392I+57Cw3IyP1f+f42ME6aEkbR+L7v1FBBWolB0xkjKTeCWVguzRrp6UiT1O6gBpJbEy2eq7AnV1rw== +conventional-changelog-writer@^4.0.18: + version "4.1.0" + resolved "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-4.1.0.tgz#1ca7880b75aa28695ad33312a1f2366f4b12659f" + integrity sha512-WwKcUp7WyXYGQmkLsX4QmU42AZ1lqlvRW9mqoyiQzdD+rJWbTepdWoKJuwXTS+yq79XKnQNa93/roViPQrAQgw== dependencies: - compare-func "^1.3.1" - conventional-commits-filter "^2.0.2" + compare-func "^2.0.0" + conventional-commits-filter "^2.0.7" dateformat "^3.0.0" - handlebars "^4.4.0" + handlebars "^4.7.6" json-stringify-safe "^5.0.1" lodash "^4.17.15" - meow "^5.0.0" + meow "^8.0.0" semver "^6.0.0" split "^1.0.0" - through2 "^3.0.0" + through2 "^4.0.0" -conventional-commits-filter@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-2.0.2.tgz#f122f89fbcd5bb81e2af2fcac0254d062d1039c1" - integrity sha512-WpGKsMeXfs21m1zIw4s9H5sys2+9JccTzpN6toXtxhpw2VNF2JUXwIakthKBy+LN4DvJm+TzWhxOMWOs1OFCFQ== +conventional-commits-filter@^2.0.7: + version "2.0.7" + resolved "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-2.0.7.tgz#f8d9b4f182fce00c9af7139da49365b136c8a0b3" + integrity sha512-ASS9SamOP4TbCClsRHxIHXRfcGCnIoQqkvAzCSbZzTFLfcTqJVugB0agRgsEELsqaeWgsXv513eS116wnlSSPA== dependencies: lodash.ismatch "^4.4.0" modify-values "^1.0.0" -conventional-commits-parser@^3.0.3: - version "3.0.8" - resolved "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.0.8.tgz#23310a9bda6c93c874224375e72b09fb275fe710" - integrity sha512-YcBSGkZbYp7d+Cr3NWUeXbPDFUN6g3SaSIzOybi8bjHL5IJ5225OSCxJJ4LgziyEJ7AaJtE9L2/EU6H7Nt/DDQ== +conventional-commits-parser@^3.2.0: + version "3.2.1" + resolved "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.1.tgz#ba44f0b3b6588da2ee9fd8da508ebff50d116ce2" + integrity sha512-OG9kQtmMZBJD/32NEw5IhN5+HnBqVjy03eC+I71I0oQRFA5rOgA4OtPOYG7mz1GkCfCNxn3gKIX8EiHJYuf1cA== dependencies: JSONStream "^1.0.4" is-text-path "^1.0.1" lodash "^4.17.15" - meow "^5.0.0" - split2 "^2.0.0" - through2 "^3.0.0" + meow "^8.0.0" + split2 "^3.0.0" + through2 "^4.0.0" trim-off-newlines "^1.0.0" -conventional-recommended-bump@^5.0.0: - version "5.0.1" - resolved "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-5.0.1.tgz#5af63903947b6e089e77767601cb592cabb106ba" - integrity sha512-RVdt0elRcCxL90IrNP0fYCpq1uGt2MALko0eyeQ+zQuDVWtMGAy9ng6yYn3kax42lCj9+XBxQ8ZN6S9bdKxDhQ== +conventional-recommended-bump@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-6.1.0.tgz#cfa623285d1de554012f2ffde70d9c8a22231f55" + integrity sha512-uiApbSiNGM/kkdL9GTOLAqC4hbptObFo4wW2QRyHsKciGAfQuLU1ShZ1BIVI/+K2BE/W1AWYQMCXAsv4dyKPaw== dependencies: concat-stream "^2.0.0" - conventional-changelog-preset-loader "^2.1.1" - conventional-commits-filter "^2.0.2" - conventional-commits-parser "^3.0.3" - git-raw-commits "2.0.0" - git-semver-tags "^2.0.3" - meow "^4.0.0" + conventional-changelog-preset-loader "^2.3.4" + conventional-commits-filter "^2.0.7" + conventional-commits-parser "^3.2.0" + git-raw-commits "^2.0.8" + git-semver-tags "^4.1.1" + meow "^8.0.0" q "^1.5.1" convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: @@ -9437,12 +10170,12 @@ copy-to-clipboard@^3, copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.1.0, copy-t dependencies: toggle-selection "^1.0.6" -core-js-compat@^3.6.2: - version "3.6.4" - resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.4.tgz#938476569ebb6cda80d339bcf199fae4f16fff17" - integrity sha512-zAa3IZPvsJ0slViBQ2z+vgyyTuhd3MFn1rBQjZSKVEgB0UMYhUkCj9jJUVPgGTGqWvsBVmfnruXgTcNyTlEiSA== +core-js-compat@^3.7.0: + version "3.8.1" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.1.tgz#8d1ddd341d660ba6194cbe0ce60f4c794c87a36e" + integrity sha512-a16TLmy9NVD1rkjUGbwuyWkiDoN0FDpAwrfLONvHFQx0D9k7J9y0srwMT8QP/Z6HE3MIFaVynEeYwZwPX1o5RQ== dependencies: - browserslist "^4.8.3" + browserslist "^4.15.0" semver "7.0.0" core-js-pure@^3.0.0, core-js-pure@^3.0.1: @@ -9451,9 +10184,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" @@ -9484,7 +10217,7 @@ cosmiconfig@6.0.0, cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" -cosmiconfig@^5.0.0, cosmiconfig@^5.1.0: +cosmiconfig@^5.0.0: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== @@ -9505,20 +10238,46 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -crc32-stream@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.0.tgz#05b7ca047d831e98c215538666f372b756d91893" - integrity sha512-tyMw2IeUX6t9jhgXI6um0eKfWq4EIDpfv5m7GX4Jzp7eVelQ360xd8EPXJhp2mHwLQIkqlnMLjzqSZI3a+0wRw== +cp-file@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz#b9454cfd07fe3b974ab9ea0e5f29655791a9b8cd" + integrity sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw== dependencies: - crc "^3.4.4" - readable-stream "^3.4.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + nested-error-stacks "^2.0.0" + p-event "^4.1.0" -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== +cpy@^8.1.1: + version "8.1.1" + resolved "https://registry.npmjs.org/cpy/-/cpy-8.1.1.tgz#066ed4c6eaeed9577df96dae4db9438c1a90df62" + integrity sha512-vqHT+9o67sMwJ5hUd/BAOYeemkU+MuFRsK2c36Xc3eefQpAsp1kAsyDxEDcc5JS1+y9l/XHPrIsVTcyGGmkUUQ== dependencies: - buffer "^5.1.0" + arrify "^2.0.1" + cp-file "^7.0.0" + globby "^9.2.0" + has-glob "^1.0.0" + junk "^3.1.0" + nested-error-stacks "^2.1.0" + p-all "^2.1.0" + p-filter "^2.1.0" + p-map "^3.0.0" + +crc-32@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== + dependencies: + exit-on-epipe "~1.0.1" + printj "~1.1.0" + +crc32-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.1.tgz#0f047d74041737f8a55e86837a1b826bd8ab0067" + integrity sha512-FN5V+weeO/8JaXsamelVYO1PHyeCsuL3HcG4cqsj0ceARcocxalaShCsohZMSAF+db7UYFwBy1rARK/0oFItUw== + dependencies: + crc-32 "^1.2.0" + readable-stream "^3.4.0" create-ecdh@^4.0.0: version "4.0.3" @@ -9559,7 +10318,7 @@ create-react-class@^15.5.1: loose-envify "^1.3.1" object-assign "^4.1.1" -create-react-context@0.3.0, create-react-context@^0.3.0: +create-react-context@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw== @@ -9567,10 +10326,15 @@ create-react-context@0.3.0, create-react-context@^0.3.0: gud "^1.0.0" warning "^4.0.3" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-env@^7.0.0: - version "7.0.2" - resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.2.tgz#bd5ed31339a93a3418ac4f3ca9ca3403082ae5f9" - integrity sha512-KZP/bMEOJEDCkDQAyRhu3RL2ZO/SUVrxQVI0G3YEQ+OLbRA3c6zgixe8Mq8a/z7+HKlNEjo8oiLUs8iRijY2Rw== + version "7.0.3" + resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== dependencies: cross-spawn "^7.0.1" @@ -9610,7 +10374,7 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -10155,12 +10919,10 @@ damerau-levenshtein@^1.0.4: resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" integrity sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug== -dargs@^4.0.1: - version "4.1.0" - resolved "https://registry.npmjs.org/dargs/-/dargs-4.1.0.tgz#03a9dbb4b5c2f139bf14ae53f0b8a2a6a86f4e17" - integrity sha1-A6nbtLXC8Tm/FK5T8LiipqhvThc= - dependencies: - number-is-nan "^1.0.0" +dargs@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc" + integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== dashdash@^1.12.0: version "1.14.1" @@ -10197,6 +10959,11 @@ dataloader@2.0.0: resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ== +date-and-time@^0.14.0: + version "0.14.1" + resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.1.tgz#969634697b78956fb66b8be6fb0f39fbd631f2f6" + integrity sha512-M4RggEH5OF2ZuCOxgOU67R6Z9ohjKbxGvAQz48vj53wLmL0bAgumkBvycR32f30pK+Og9pIR+RFDyChbaE4oLA== + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" @@ -10234,17 +11001,10 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@3.1.0, debug@=3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" - integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== +debug@4, debug@4.3.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0: + version "4.3.1" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: ms "2.1.2" @@ -10255,6 +11015,13 @@ debug@4.1.1: dependencies: ms "^2.1.1" +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: version "3.2.6" resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -10267,7 +11034,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.1.0: version "1.1.0" resolved "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk= @@ -10309,13 +11076,6 @@ decompress-response@^4.2.0: dependencies: mimic-response "^2.0.0" -decompress-response@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-5.0.0.tgz#7849396e80e3d1eba8cb2f75ef4930f76461cb0f" - integrity sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw== - dependencies: - mimic-response "^2.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -10328,7 +11088,7 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-equal@^1.0.1, deep-equal@^1.1.1: +deep-equal@^1.0.1: version "1.1.1" resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== @@ -10456,7 +11216,7 @@ delegates@^1.0.0: resolved "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -depd@~1.1.2: +depd@^1.1.2, depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= @@ -10466,6 +11226,11 @@ depd@~2.0.0: resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +dependency-graph@^0.10.0: + version "0.10.0" + resolved "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.10.0.tgz#dfebe384f1f36faf7782be203a7a71102a6335a6" + integrity sha512-c9amUgpgxSi1bE5/sbLwcs5diLD0ygCQYmhfM5H1s5VH1mCsYkcmAL3CcNdv4kdSw6JuMoHeDGzLgj/gAXdWVg== + dependency-graph@^0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.9.0.tgz#11aed7e203bc8b00f48356d92db27b265c445318" @@ -10509,7 +11274,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= @@ -10564,16 +11329,26 @@ dicer@0.3.0: dependencies: streamsearch "0.1.2" -diff-sequences@^25.2.6: - version "25.2.6" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" - integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== +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" integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== +diff-sequences@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" + integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== + +diff3@0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz#d4e5c3a4cdf4e5fe1211ab42e693fcb4321580fc" + integrity sha1-1OXDpM305f4SEatC5pP8tDIVgPw= + diff@^4.0.1, diff@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -10640,7 +11415,7 @@ docker-modem@^2.1.0: split-ca "^1.0.1" ssh2 "^0.8.7" -dockerode@^3.2.0, dockerode@^3.2.1: +dockerode@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.2.1.tgz#4a2222e3e1df536bf595e78e76d3cfbf6d4d93b9" integrity sha512-XsSVB5Wu5HWMg1aelV5hFSqFJaKS5x1aiV/+sT7YOzOq1IRl49I/UwV8Pe4x6t0iF9kiGkWu5jwfvbkcFVupBw== @@ -10670,10 +11445,10 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.1: - version "0.5.2" - resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.2.tgz#ef3cdb5d3f0d599d8f9c8b18df2fb63c9793739d" - integrity sha512-k7hRNKAiPJXD2aBqfahSo4/01cTsKWXf+LqJgglnkN2Nz8TsxXKQBXHhKe0Ye9fEfHEZY49uSA5Sr3AqP/sWKA== +dom-accessibility-api@^0.5.4: + version "0.5.4" + resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" + integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== dom-converter@^0.2: version "0.2.0" @@ -10705,6 +11480,15 @@ dom-serializer@0, dom-serializer@^0.2.1: domelementtype "^2.0.1" entities "^2.0.0" +dom-serializer@^1.0.1: + version "1.2.0" + resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" + integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + entities "^2.0.0" + dom-walk@^0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" @@ -10725,6 +11509,11 @@ domelementtype@^2.0.1: resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== +domelementtype@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" + integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== + domexception@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" @@ -10753,20 +11542,17 @@ domhandler@^3.0, domhandler@^3.0.0: dependencies: domelementtype "^2.0.1" -dompurify@=2.2.2: - version "2.2.2" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.2.tgz#cb8c2b1a2f3c8a0b565127504ae4eedec176a972" - integrity sha512-BsGR4nDLaC5CNBnyT5I+d5pOeaoWvgVeg6Gq/aqmKYWMPR07131u60I80BvExLAJ0FQEIBQ1BTicw+C5+jOyrg== +domhandler@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e" + integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA== + dependencies: + domelementtype "^2.1.0" -dompurify@^2.0.12: - version "2.1.1" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" - integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg== - -dompurify@^2.1.1: - version "2.2.0" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.0.tgz#51d34e76faa38b5d6b4e83a0678530f27fe3965c" - integrity sha512-bqFOQ7XRmmozp0VsKdIEe8UwZYxj0yttz7l80GBtBqdVRY48cOpXH2J/CVO7AEkV51qY0EBVXfilec18mdmQ/w== +dompurify@^2.0.12, dompurify@^2.1.1, dompurify@^2.2.3: + version "2.2.3" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.3.tgz#ec653ba521b39f397c2ca045769438d593ea8a9f" + integrity sha512-8Hv7Q0FuwD9rWoB6qI2eZsfKbGXfoUVuGHHrE15vgk4ReOKwOkSgbqb2OMFtc0d5besOEkoLkcyuV10zQ2X5gw== domutils@1.5.1: version "1.5.1" @@ -10793,6 +11579,15 @@ domutils@^2.0.0: domelementtype "^2.0.1" domhandler "^3.0.0" +domutils@^2.4.4: + version "2.4.4" + resolved "https://registry.npmjs.org/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" + integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.0.1" + domhandler "^4.0.0" + dot-case@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/dot-case/-/dot-case-3.0.3.tgz#21d3b52efaaba2ea5fda875bb1aa8124521cf4aa" @@ -10801,19 +11596,12 @@ dot-case@^3.0.3: no-case "^3.0.3" tslib "^1.10.0" -dot-prop@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177" - integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc= +dot-prop@^5.1.0: + 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 "^1.0.0" - -dot-prop@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" - integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== - dependencies: - is-obj "^1.0.0" + is-obj "^2.0.0" dot-prop@^5.2.0: version "5.2.0" @@ -10822,6 +11610,13 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dot-prop@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz#fc26b3cf142b9e59b74dbd39ed66ce620c681083" + integrity sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA== + dependencies: + is-obj "^2.0.0" + dotenv-defaults@^1.0.2: version "1.1.1" resolved "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd" @@ -10851,6 +11646,28 @@ dotenv@^8.0.0, dotenv@^8.2.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +downshift@^6.0.6: + version "6.0.7" + resolved "https://registry.npmjs.org/downshift/-/downshift-6.0.7.tgz#05ef58b991e4467cdb8cd36e7a87a011d0bada5c" + integrity sha512-+rqgx3JTSs8b4V9q++hsLvaP8mhMOdX7u+jy5S2etg7+Y7uFLVzZaI0S7Xo2yEnNDmTcNnYZZ/vFjdWuJ2kZdg== + dependencies: + "@babel/runtime" "^7.12.5" + compute-scroll-into-view "^1.0.16" + prop-types "^15.7.2" + react-is "^17.0.1" + +dset@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/dset/-/dset-3.1.0.tgz#23feb6df93816ea452566308b1374d6e869b0d7b" + integrity sha512-7xTQ5DzyE59Nn+7ZgXDXjKAGSGmXZHqttMVVz1r4QNfmGpyj+cm2YtI3II0c/+4zS4a9yq2mBhgdeq2QnpcYlw== + +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" @@ -10878,6 +11695,16 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +duplexify@^4.0.0, duplexify@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz#7027dc374f157b122a8ae08c2d3ea4d2d953aa61" + integrity sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA== + dependencies: + end-of-stream "^1.4.1" + inherits "^2.0.3" + readable-stream "^3.1.1" + stream-shift "^1.0.0" + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -10886,7 +11713,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -10910,6 +11737,11 @@ electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.488: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.509.tgz#830fcb89cd66dc2984d18d794973b99e3f00584c" integrity sha512-cN4lkjNRuTG8rtAqTOVgwpecEC2kbKA04PG6YijcKGHK/kD0xLjiqExcAOmLUwtXZRF8cBeam2I0VZcih919Ug== +electron-to-chromium@^1.3.612: + version "1.3.620" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.620.tgz#c6f36a7e398acc9d7d12743a6f58d536fbc58700" + integrity sha512-YbgWXUR2Mu+Fp6rm3GZ5YJdNo8SgZKLUTNSl2PNvdOcM8OIz07jRJnRkIaV9vdszFv9UUuGChh19w9qSuoLJgw== + elegant-spinner@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" @@ -10991,12 +11823,12 @@ encodeurl@~1.0.2: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@^0.1.11: - version "0.1.12" - resolved "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= +encoding@^0.1.12: + version "0.1.13" + resolved "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== dependencies: - iconv-lite "~0.4.13" + iconv-lite "^0.6.2" end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" @@ -11030,6 +11862,11 @@ enquirer@^2.3.0, enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +ent@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" + integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= + entities@^1.1.1, entities@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -11050,15 +11887,15 @@ env-variable@0.0.x: resolved "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz#74ab20b3786c545b62b4a4813ab8cf22726c9808" integrity sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg== -envinfo@^7.3.1: - version "7.5.0" - resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.5.0.tgz#91410bb6db262fb4f1409bd506e9ff57e91023f4" - integrity sha512-jDgnJaF/Btomk+m3PZDTTCb5XIIIX3zYItnCRfF73zVgvinLoRomuhi75Y4su0PtQxWz4v66XnLLckyvyJTOIQ== +envinfo@^7.7.4: + version "7.7.4" + resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.7.4.tgz#c6311cdd38a0e86808c1c9343f667e4267c4a320" + integrity sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ== -err-code@^1.0.0: - version "1.1.2" - resolved "https://registry.npmjs.org/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960" - integrity sha1-BuARbTAo9q70gGhJ6w6mp0iuaWA= +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== errno@^0.1.3, errno@~0.1.7: version "0.1.7" @@ -11183,18 +12020,6 @@ es6-iterator@^2.0.3, es6-iterator@~2.0.3: es5-ext "^0.10.35" es6-symbol "^3.1.1" -es6-promise@^4.0.3: - version "4.2.8" - resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - es6-shim@^0.35.5: version "0.35.5" resolved "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.5.tgz#46f59dc0a84a1c5029e8ff1166ca0a902077a9ab" @@ -11253,6 +12078,11 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escodegen@^1.14.1, escodegen@^1.9.1: version "1.14.3" resolved "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" @@ -11283,10 +12113,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" @@ -11317,16 +12147,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" @@ -11336,9 +12166,9 @@ eslint-plugin-import@^2.20.2: tsconfig-paths "^3.9.0" eslint-plugin-jest@^24.1.0: - version "24.1.0" - resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.0.tgz#6708037d7602e5288ce877fd0103f329dc978361" - integrity sha512-827YJ+E8B9PvXu/0eiVSNFfxxndbKv+qE/3GSMhdorCaeaOehtqHGX2YDW9B85TEOre9n/zscledkFW/KbnyGg== + version "24.1.5" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.5.tgz#1e866a9f0deac587d0a3d5d7cefe99815a580de2" + integrity sha512-FIP3lwC8EzEG+rOs1y96cOJmMVpdFNreoDJv29B5vIupVssRi8zrSY3QadogT0K3h1Y8TMxJ6ZSAzYUmFCp2hg== dependencies: "@typescript-eslint/experimental-utils" "^4.0.1" @@ -11357,10 +12187,10 @@ eslint-plugin-jsx-a11y@^6.2.1: has "^1.0.3" jsx-ast-utils "^2.2.1" -eslint-plugin-monorepo@^0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/eslint-plugin-monorepo/-/eslint-plugin-monorepo-0.2.1.tgz#96cfc4af241077675f40d7017377897fb8ea537b" - integrity sha512-82JaAjuajVAsDT+pMvdt275H6F55H3MEofaMZbJurGqfXpPDT4eayTgYyyjfd1XR8VD1S+ORbuHCULnSqNyD9g== +eslint-plugin-monorepo@^0.3.2: + version "0.3.2" + resolved "https://registry.npmjs.org/eslint-plugin-monorepo/-/eslint-plugin-monorepo-0.3.2.tgz#bc546cbe84b21ae6a7622f261bf9fe73b1524367" + integrity sha512-CypTAqHjTR05XxzqDj7x88oVu2GiqqQA/datD9kIwciHzpj0oE4YbTdyEFFKADgd7dbd21KliSlUpOvo626FBw== dependencies: eslint-module-utils "^2.1.1" get-monorepo-packages "^1.1.0" @@ -11380,9 +12210,9 @@ eslint-plugin-notice@^0.9.10: metric-lcs "^0.1.2" eslint-plugin-react-hooks@^4.0.0: - version "4.0.4" - resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.4.tgz#aed33b4254a41b045818cacb047b81e6df27fa58" - integrity sha512-equAdEIsUETLFNCmmCkiCGq6rkSK5MoJhXFPFYeUebcjKgBmWWcgVOqZyQC8Bv1BwVCnTq9tBxgJFgAJTWoJtA== + version "4.2.0" + resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" + integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== eslint-plugin-react@^7.12.4: version "7.21.5" @@ -11491,7 +12321,7 @@ espree@^7.3.0: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.3.0" -esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: +esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -11522,12 +12352,7 @@ estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -estraverse@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" - integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== - -estraverse@^5.2.0: +estraverse@^5.1.0, estraverse@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== @@ -11578,6 +12403,11 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter2@^6.4.2: version "6.4.3" resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" @@ -11593,6 +12423,16 @@ eventemitter3@^4.0.0: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/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" @@ -11675,6 +12515,21 @@ execa@^4.0.0, execa@^4.0.1: signal-exit "^3.0.2" strip-final-newline "^2.0.0" +execa@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" + integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + executable@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" @@ -11687,6 +12542,11 @@ exit-hook@^1.0.0: resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + exit@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -11705,6 +12565,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" @@ -11712,26 +12577,30 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/expect/-/expect-26.5.3.tgz#89d9795036f7358b0a9a5243238eb8086482d741" - integrity sha512-kkpOhGRWGOr+TEFUnYAjfGvv35bfP+OlPtqPIJpOCR9DVtv8QV+p8zG0Edqafh80fsjeE+7RBcVUq1xApnYglw== +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" "^26.5.2" + "@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" + integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== + dependencies: + "@jest/types" "^26.6.2" ansi-styles "^4.0.0" jest-get-type "^26.3.0" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" -express-prom-bundle@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.1.0.tgz#8fd72e5bedbbd686b8e7c49e0aecbc1b14b28743" - integrity sha512-krlvp5r6sgJ1IwL6M6/coMrNbAlwtpk+uyivfeyRMCupTK4HzIEQHH0gwrNhLiKyPmSbtZcSmtO6s+PRumRp5g== - dependencies: - on-finished "^2.3.0" - url-value-parser "^2.0.0" - express-promise-router@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-3.0.3.tgz#5e6d22a5a3f013d71833172fe8d7ab780c3f6b70" @@ -11813,7 +12682,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@3.0.2, extend@^3.0.0, extend@~3.0.2: +extend@3.0.2, extend@^3.0.0, extend@^3.0.2, extend@~3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -11940,6 +12809,11 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== +fast-text-encoding@^1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" + integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== + 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" @@ -11957,7 +12831,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fault@^1.0.0, fault@^1.0.2: +fault@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13" integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA== @@ -12020,7 +12894,7 @@ fetch-readablestream@^0.2.0: resolved "https://registry.npmjs.org/fetch-readablestream/-/fetch-readablestream-0.2.0.tgz#eaa6d1a76b12de2d4731a343393c6ccdcfe2c795" integrity sha512-qu4mXWf4wus4idBIN/kVH+XSer8IZ9CwHP+Pd7DL7TuKNC1hP7ykon4kkBjwJF3EMX2WsFp4hH7gU7CyL7ucXw== -figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: +figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== @@ -12062,6 +12936,14 @@ file-loader@^6.0.0: loader-utils "^2.0.0" schema-utils "^2.7.1" +file-loader@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" + integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + file-system-cache@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f" @@ -12184,12 +13066,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" @@ -12411,7 +13293,7 @@ fs-extra@8.1.0, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@9.0.1, fs-extra@^9.0.0, fs-extra@^9.0.1: +fs-extra@9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== @@ -12432,7 +13314,7 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^7.0.0, fs-extra@^7.0.1: +fs-extra@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== @@ -12441,6 +13323,16 @@ fs-extra@^7.0.0, fs-extra@^7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-minipass@^1.2.5: version "1.2.7" resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" @@ -12448,7 +13340,7 @@ fs-minipass@^1.2.5: dependencies: minipass "^2.6.0" -fs-minipass@^2.0.0: +fs-minipass@^2.0.0, fs-minipass@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== @@ -12536,6 +13428,49 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +gaxios@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-3.2.0.tgz#11b6f0e8fb08d94a10d4d58b044ad3bec6dd486a" + integrity sha512-+6WPeVzPvOshftpxJwRi2Ozez80tn/hdtOUag7+gajDHRJvAblKxTFSSMPtr2hmnLy7p0mvYz0rMXLBl8pSO7Q== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.3.0" + +gaxios@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-4.0.1.tgz#bc7b205a89d883452822cc75e138620c35e3291e" + integrity sha512-jOin8xRZ/UytQeBpSXFqIzqU7Fi5TqgPNLlUsSB8kjJ76+FiGBfImF8KJu++c6J4jOldfJUtt0YmkRj2ZpSHTQ== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.3.0" + +gcp-metadata@^4.2.0: + version "4.2.1" + resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz#31849fbcf9025ef34c2297c32a89a1e7e9f2cd62" + integrity sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw== + dependencies: + gaxios "^4.0.0" + json-bigint "^1.0.0" + +gcs-resumable-upload@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.1.tgz#67c766a0555d6a352f9651b7603337207167d0de" + integrity sha512-RS1osvAicj9+MjCc6jAcVL1Pt3tg7NK2C2gXM5nqD1Gs0klF2kj5nnAFSBy97JrtslMIQzpb7iSuxaG8rFWd2A== + dependencies: + abort-controller "^3.0.0" + configstore "^5.0.0" + extend "^3.0.2" + gaxios "^3.0.0" + google-auth-library "^6.0.0" + pumpify "^2.0.0" + stream-events "^1.0.4" + generic-names@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872" @@ -12548,11 +13483,6 @@ generic-pool@2.4.3: resolved "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff" integrity sha1-eAw29p360FpaBF3Te+etyhGk9v8= -genfun@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537" - integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA== - 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" @@ -12601,11 +13531,6 @@ get-pkg-repo@^1.0.0: parse-github-repo-url "^1.3.0" through2 "^2.0.0" -get-port@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119" - integrity sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw== - get-port@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" @@ -12640,6 +13565,11 @@ get-stream@^5.0.0, get-stream@^5.1.0: dependencies: pump "^3.0.0" +get-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" + integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -12664,16 +13594,16 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -git-raw-commits@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.0.tgz#d92addf74440c14bcc5c83ecce3fb7f8a79118b5" - integrity sha512-w4jFEJFgKXMQJ0H0ikBk2S+4KP2VEjhCvLCNqbNRQC8BgGWgLKNCO7a9K9LI+TVT7Gfoloje502sEnctibffgg== +git-raw-commits@^2.0.8: + version "2.0.10" + resolved "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.10.tgz#e2255ed9563b1c9c3ea6bd05806410290297bbc1" + integrity sha512-sHhX5lsbG9SOO6yXdlwgEMQ/ljIn7qMpAbJZCGfXX2fq5T8M5SrDnpYk9/4HswTildcIqatsWa91vty6VhWSaQ== dependencies: - dargs "^4.0.1" - lodash.template "^4.0.2" - meow "^4.0.0" - split2 "^2.0.0" - through2 "^2.0.0" + dargs "^7.0.0" + lodash "^4.17.15" + meow "^8.0.0" + split2 "^3.0.0" + through2 "^4.0.0" git-remote-origin-url@^2.0.0: version "2.0.0" @@ -12683,12 +13613,12 @@ git-remote-origin-url@^2.0.0: gitconfiglocal "^1.0.0" pify "^2.3.0" -git-semver-tags@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-2.0.3.tgz#48988a718acf593800f99622a952a77c405bfa34" - integrity sha512-tj4FD4ww2RX2ae//jSrXZzrocla9db5h0V7ikPl1P/WwoZar9epdUhwR7XHXSgc+ZkNq72BEEerqQuicoEQfzA== +git-semver-tags@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-4.1.1.tgz#63191bcd809b0ec3e151ba4751c16c444e5b5780" + integrity sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA== dependencies: - meow "^4.0.0" + meow "^8.0.0" semver "^6.0.0" git-up@^4.0.0: @@ -12699,17 +13629,10 @@ git-up@^4.0.0: is-ssh "^1.3.0" parse-url "^5.0.0" -git-url-parse@^11.1.2: - version "11.1.3" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.3.tgz#03625b6fc09905e9ad1da7bb2b84be1bf9123143" - integrity sha512-GPsfwticcu52WQ+eHp0IYkAyaOASgYdtsQDIt4rUp6GbiNt1P9ddrh3O0kQB0eD4UJZszVqNT3+9Zwcg40fywA== - dependencies: - git-up "^4.0.0" - -git-url-parse@^11.4.0: - version "11.4.0" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.0.tgz#f2bb1f2b00f05552540e95a62e31399a639a6aa6" - integrity sha512-KlIa5jvMYLjXMQXkqpFzobsyD/V2K5DRHl5OAf+6oDFPlPLxrGDVQlIdI63c4/Kt6kai4kALENSALlzTGST3GQ== +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" @@ -12720,6 +13643,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" @@ -12750,7 +13678,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: +glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@~5.1.0: version "5.1.1" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== @@ -12781,6 +13709,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" @@ -12851,7 +13790,7 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.1, globby@^11.0.0, globby@^11.0.1: +globby@11.0.1: version "11.0.1" resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== @@ -12890,6 +13829,18 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" +globby@^11.0.0, globby@^11.0.1, globby@^11.0.2: + version "11.0.2" + resolved "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" + integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" @@ -12934,26 +13885,27 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" -got@^10.7.0: - version "10.7.0" - resolved "https://registry.npmjs.org/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" - integrity sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg== +google-auth-library@^6.0.0, google-auth-library@^6.1.1: + version "6.1.3" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.3.tgz#39d868140b70d0c4b32c6f6d8f4ccc1400d84dca" + integrity sha512-m9mwvY3GWbr7ZYEbl61isWmk+fvTmOt0YNUfPOUY2VH8K5pZlAIWJjxEi0PqR3OjMretyiQLI6GURMrPSwHQ2g== dependencies: - "@sindresorhus/is" "^2.0.0" - "@szmarczak/http-timer" "^4.0.0" - "@types/cacheable-request" "^6.0.1" - cacheable-lookup "^2.0.0" - cacheable-request "^7.0.1" - decompress-response "^5.0.0" - duplexer3 "^0.1.4" - get-stream "^5.0.0" - lowercase-keys "^2.0.0" - mimic-response "^2.1.0" - p-cancelable "^2.0.0" - p-event "^4.0.0" - responselike "^2.0.0" - to-readable-stream "^2.0.0" - type-fest "^0.10.0" + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-p12-pem@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz#673ac3a75d3903a87f05878f3c75e06fc151669e" + integrity sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA== + dependencies: + node-forge "^0.10.0" got@^11.5.2: version "11.6.0" @@ -12972,23 +13924,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" @@ -13028,26 +13963,29 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.5 resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +graceful-fs@^4.2.3: + version "4.2.6" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== graphiql@^1.0.0-alpha.10: - version "1.0.3" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.3.tgz#f6b5d5c417d8f1a28786d3228a69883426ba74ad" - integrity sha512-6YW76m2exdpGiVS75I0QsbM4NanvenKFR9FtW/IFbui0mWv89iVa1FbdjhUt+kxa+LX/WlqTshiqiZRz7E5Bgg== + version "1.4.0" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.4.0.tgz#8b17c988720a9da5ea3ebf7ff9d86939b462655e" + integrity sha512-E/Xzfu3YnifdINrK6dKHD1G0qnWkYYK2gHQ//vApUadMb4I4lZQ399ZKt5nqM5kzrATf5FDUTuSpnkaeSoARkQ== dependencies: - "@emotion/core" "^10.0.28" - "@mdx-js/react" "^1.5.2" - codemirror "^5.52.2" - codemirror-graphql "^0.12.0" + "@graphiql/toolkit" "^0.1.0" + codemirror "^5.54.0" + codemirror-graphql "^1.0.0" copy-to-clipboard "^3.2.0" + dset "^3.0.0" entities "^2.0.0" + graphql-language-service "^3.1.2" markdown-it "^10.0.0" - monaco-graphql "^0.2.0" - regenerator-runtime "^0.13.5" - theme-ui "^0.3.1" graphlib@^2.1.8: version "2.1.8" @@ -13072,49 +14010,52 @@ graphql-config@^3.0.2: string-env-interpolation "1.0.1" tslib "^2.0.0" -graphql-extensions@^0.12.4: - version "0.12.4" - resolved "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.4.tgz#c0aa49a20f983a2da641526d1e505996bd2b4188" - integrity sha512-GnR4LiWk3s2bGOqIh6V1JgnSXw2RCH4NOgbCFEWvB6JqWHXTlXnLZ8bRSkCiD4pltv7RHUPWqN/sGh8R6Ae/ag== +graphql-extensions@^0.12.8: + version "0.12.8" + resolved "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.8.tgz#9cdc2c43d8fe5e0f6c3177a004ac011da2a8aa0f" + integrity sha512-xjsSaB6yKt9jarFNNdivl2VOx52WySYhxPgf8Y16g6GKZyAzBoIFiwyGw5PJDlOSUa6cpmzn6o7z8fVMbSAbkg== dependencies: "@apollographql/apollo-tools" "^0.4.3" - apollo-server-env "^2.4.5" - apollo-server-types "^0.5.1" + apollo-server-env "^3.0.0" + apollo-server-types "^0.6.3" -graphql-language-service-interface@^2.4.0: - version "2.4.0" - resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0.tgz#4e2e63242c76197c4f56c61122db68187ea566b3" - integrity sha512-r7DQPyhCFY5TlpEukdh9tekJ9hAc7MD9TdOsb5CfAPlsIb1/faVVo2Ty19PxGSYDxygXjwpKLOQD0LqqFuw63A== +graphql-language-service-interface@^2.8.2: + version "2.8.2" + resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.8.2.tgz#b3bb2aef7eaf0dff0b4ea419fa412c5f66fa268b" + integrity sha512-otbOQmhgkAJU1QJgQkMztNku6SbJLu/uodoFOYOOtJsizTjrMs93vkYaHCcYnLA3oi1Goj27XcHjMnRCYQOZXQ== dependencies: - graphql-language-service-parser "^1.6.0" - graphql-language-service-types "^1.6.0" - graphql-language-service-utils "^2.4.0" + graphql-language-service-parser "^1.9.0" + graphql-language-service-types "^1.8.0" + graphql-language-service-utils "^2.5.1" vscode-languageserver-types "^3.15.1" -graphql-language-service-parser@^1.6.0: - version "1.6.0" - resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.6.0.tgz#53a97619461ed41ae237b9070e7e20cfaca56118" - integrity sha512-tkfYXl6WBECWNcsyw7O09c0oCMrxqr3JGyDNvdISDSDhvk8EwEk+AOweBEJkycJwoiv1lVlM+EdLfj7dzw4/6w== - -graphql-language-service-types@^1.6.0: - version "1.6.0" - resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.6.0.tgz#32529bb9d2e3b8ae9dd6d9646e15481ac5dfe9ad" - integrity sha512-7y4pkhTL+MtarC8CeHpvRYVc6pkT6ITdQXs+Gr7OClnk4Lz5nQEK0eO29kUdd0jEnYfHyh5iqhnL5Owfy+wT0A== - -graphql-language-service-utils@^2.4.0: - version "2.4.0" - resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.4.0.tgz#d4992a1968860abc43a118de498fec84361a4b87" - integrity sha512-aLa+8iqb7AJYgdcawsKH8/KLc14DHcRsnveshOm8hN6bBVT0YiQP6mEfJoci741O74uaDF2zj1J3c02vorichw== +graphql-language-service-parser@^1.9.0: + version "1.9.0" + resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.9.0.tgz#79af21294119a0a7e81b6b994a1af36833bab724" + integrity sha512-B5xPZLbBmIp0kHvpY1Z35I5DtPoDK9wGxQVRDIzcBaiIvAmlTrDvjo3bu7vKREdjFbYKvWNgrEWENuprMbF17Q== dependencies: - graphql-language-service-types "^1.6.0" + graphql-language-service-types "^1.8.0" -graphql-language-service@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.0.0.tgz#953c3bbf4cc35ff9a7bd54e428bca17832367490" - integrity sha512-ySPrbecxtjQtzIFdCIOoSJzaco9dQvZK+wZN/tTjcSEEWh3fgMYXmctAG+WddJiZg3AB7I4Xsb2AsO5yFBbLHg== +graphql-language-service-types@^1.8.0: + version "1.8.1" + resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.8.1.tgz#963810010924f2b5eaea415d5b8eb0b7d42c479b" + integrity sha512-IpYS0mEHEmRsFlq+loWCpSYYYizAID7Alri6GoFN1QqUdux+8rp1Tkp2NGsGDpDmm3Dbz5ojmJWzNWQGpuwveA== + +graphql-language-service-utils@^2.5.1: + version "2.5.1" + resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.5.1.tgz#832ad4b0a9da03fdded756932c27e057ccf71302" + integrity sha512-Lzz723cYrYlVN4WVzIyFGg3ogoe+QYAIBfdtDboiIILoy0FTmqbyC2TOErqbmWKqO4NK9xDA95cSRFbWiHYj0g== dependencies: - graphql-language-service-interface "^2.4.0" - graphql-language-service-types "^1.6.0" + graphql-language-service-types "^1.8.0" + nullthrows "^1.0.0" + +graphql-language-service@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.1.2.tgz#6f50d5d824ea09c402cb02902b10e54b9da899d5" + integrity sha512-OiOH8mVE+uotrl3jGA2Pgt9k7rrI8lgw/8p+Cf6nwyEHbmIZj37vX9KoOWgpdFhuQlw824nNxWHSbz6k90xjWQ== + dependencies: + graphql-language-service-interface "^2.8.2" + graphql-language-service-types "^1.8.0" graphql-request@^3.1.0: version "3.1.0" @@ -13137,11 +14078,6 @@ graphql-tag@^2.11.0: resolved "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.11.0.tgz#1deb53a01c46a7eb401d6cb59dec86fa1cccbffd" integrity sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA== -graphql-tag@^2.9.2: - version "2.10.4" - resolved "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.4.tgz#2f301a98219be8b178a6453bb7e33b79b66d8f83" - integrity sha512-O7vG5BT3w6Sotc26ybcvLKNTdfr4GfsIVMD+LdYqXCeJIYPRyp8BIsDOUtxw7S1PYvRw5vH3278J2EDezR6mfA== - graphql-tools@5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/graphql-tools/-/graphql-tools-5.0.0.tgz#67281c834a0e29f458adba8018f424816fa627e9" @@ -13156,7 +14092,7 @@ graphql-tools@5.0.0: tslib "^1.11.1" uuid "^7.0.3" -graphql-tools@^4.0.0: +graphql-tools@^4.0.0, graphql-tools@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/graphql-tools/-/graphql-tools-4.0.8.tgz#e7fb9f0d43408fb0878ba66b522ce871bafe9d30" integrity sha512-MW+ioleBrwhRjalKjYaLQbr+920pHBgy9vM/n47sswtns8+96sRn5M/G+J1eu7IMeKWiN/9p6tmwCHU7552VJg== @@ -13172,33 +14108,31 @@ graphql-type-json@^0.3.2: resolved "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz#f53a851dbfe07bd1c8157d24150064baab41e115" integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg== -graphql-upload@^8.0.2: - version "8.1.0" - resolved "https://registry.npmjs.org/graphql-upload/-/graphql-upload-8.1.0.tgz#6d0ab662db5677a68bfb1f2c870ab2544c14939a" - integrity sha512-U2OiDI5VxYmzRKw0Z2dmfk0zkqMRaecH9Smh1U277gVgVe9Qn+18xqf4skwr4YJszGIh7iQDZ57+5ygOK9sM/Q== - dependencies: - busboy "^0.3.1" - fs-capacitor "^2.0.4" - http-errors "^1.7.3" - object-path "^0.11.4" +graphql-ws@^4.1.0: + version "4.2.0" + resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.2.0.tgz#dfb8e355d3a226672ec682ea517b64e9e7e006a3" + integrity sha512-tPdRAFfl9Ypx+evsSYFa3nTGVcSKKaOMhTlOD7eEbiMpsb2hi6XJeSyenrH/hgf5+Q3v3GV6aZ9R/UEjakl7eQ== -graphql@15.3.0, graphql@^15.3.0: - version "15.3.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" - integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== - -graphql@^14.5.3: - version "14.7.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-14.7.0.tgz#7fa79a80a69be4a31c27dda824dc04dac2035a72" - integrity sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA== - dependencies: - iterall "^1.2.2" +graphql@15.5.0, graphql@^15.3.0: + version "15.5.0" + resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" + integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== growly@^1.3.0: version "1.3.0" - resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= +gtoken@^5.0.4: + version "5.1.0" + resolved "https://registry.npmjs.org/gtoken/-/gtoken-5.1.0.tgz#4ba8d2fc9a8459098f76e7e8fd7beaa39fda9fe4" + integrity sha512-4d8N6Lk8TEAHl9vVoRVMh9BNOKWVgl2DdNtr3428O75r3QFrF/a5MMu851VmK0AA8+iSvbwRv69k5XnMLURGhg== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.0.3" + jws "^4.0.0" + mime "^2.2.0" + gud@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -13217,7 +14151,7 @@ handle-thing@^2.0.0: resolved "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== -handlebars@^4.4.0, handlebars@^4.7.3: +handlebars@^4.7.3, handlebars@^4.7.6: version "4.7.6" resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" integrity sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA== @@ -13242,6 +14176,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" @@ -13269,6 +14208,13 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz#9aaa9eedbffb1ba3990a7b0010fb678ee0081207" + integrity sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc= + dependencies: + is-glob "^3.0.0" + has-symbols@^1.0.0, has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" @@ -13330,6 +14276,11 @@ hash-base@^3.0.0: inherits "^2.0.1" safe-buffer "^5.0.1" +hash-stream-validation@^0.2.2: + version "0.2.4" + resolved "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz#ee68b41bf822f7f44db1142ec28ba9ee7ccb7512" + integrity sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ== + hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" @@ -13353,6 +14304,17 @@ hastscript@^5.0.0: property-information "^5.0.0" space-separated-tokens "^1.0.0" +hastscript@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" + integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== + dependencies: + "@types/hast" "^2.0.0" + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + he@^1.1.0, he@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -13378,22 +14340,10 @@ highlight.js@^10.1.1, highlight.js@~10.1.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.1.2.tgz#c20db951ba1c22c055010648dfffd7b2a968e00c" integrity sha512-Q39v/Mn5mfBlMff9r+zzA+gWxRsCRKwEMvYTiisLr/XUiFI/4puWt0Ojdko3R3JCNWGdOWaA5g/Yxqa23kC5AA== -highlight.js@~9.15.0, highlight.js@~9.15.1: - version "9.15.10" - resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" - integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== - -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" +highlight.js@^10.4.1, highlight.js@~10.4.0: + version "10.4.1" + resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" + integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== history@^5.0.0: version "5.0.0" @@ -13411,7 +14361,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== @@ -13430,11 +14380,18 @@ hoopy@^0.1.4: resolved "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== -hosted-git-info@^2.1.4, hosted-git-info@^2.7.1: +hosted-git-info@^2.1.4: version "2.8.8" resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== +hosted-git-info@^3.0.6: + version "3.0.8" + resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz#6e35d4cc87af2c5f816e4cb9ce350ba87a3f370d" + integrity sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw== + dependencies: + lru-cache "^6.0.0" + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -13479,6 +14436,11 @@ html-entities@^1.2.0, html-entities@^1.3.1: resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== +html-entities@^1.2.1: + version "1.3.3" + resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.3.3.tgz#3dca638a43ee7de316fc23067398491152ad4736" + integrity sha512-/VulV3SYni1taM7a4RMdceqzJWR39gpZHjBwUnsCFKWV/GJkD14CJ5F7eWcZozmHJK0/f/H5U3b3SiPkuvxMgg== + html-escaper@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.1.tgz#beed86b5d2b921e92533aa11bce6d8e3b583dee7" @@ -13541,7 +14503,7 @@ htmlparser2@^3.3.0: inherits "^2.0.1" readable-stream "^3.1.1" -htmlparser2@^4.0, htmlparser2@^4.1.0: +htmlparser2@^4.0: version "4.1.0" resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== @@ -13551,12 +14513,17 @@ htmlparser2@^4.0, htmlparser2@^4.1.0: domutils "^2.0.0" entities "^2.0.0" -http-cache-semantics@^3.8.1: - version "3.8.1" - resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" - integrity sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w== +htmlparser2@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01" + integrity sha512-numTQtDZMoh78zJpaNdJ9MXb2cv5G3jwUoe3dMQODubZvLoGvTE/Ofp6sHvH8OGKcN/8A47pGLi/k58xHP/Tfw== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.4.4" + entities "^2.0.0" -http-cache-semantics@^4.0.0: +http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== @@ -13614,15 +14581,7 @@ http-errors@~1.7.2: resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4" integrity sha1-ksnBN0w1CF912zWexWzCV8u5P6Q= -http-proxy-agent@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" - integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg== - dependencies: - agent-base "4" - debug "3.1.0" - -http-proxy-agent@^4.0.1: +http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== @@ -13682,14 +14641,6 @@ https-browserify@^1.0.0: resolved "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@^2.2.3: - version "2.2.4" - resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" - integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== - dependencies: - agent-base "^4.3.0" - debug "^3.1.0" - https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -13708,6 +14659,16 @@ human-signals@^1.1.1: resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +humanize-duration@^3.25.1: + version "3.25.1" + resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.25.1.tgz#50e12bf4b3f515ec91106107ee981e8cfe955d6f" + integrity sha512-P+dRo48gpLgc2R9tMRgiDRNULPKCmqFYgguwqOO2C0fjO35TgdURDQDANSR1Nt92iHlbHGMxOTnsB8H8xnMa2Q== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -13716,17 +14677,17 @@ humanize-ms@^1.2.1: ms "^2.0.0" husky@^4.2.3: - version "4.3.0" - resolved "https://registry.npmjs.org/husky/-/husky-4.3.0.tgz#0b2ec1d66424e9219d359e26a51c58ec5278f0de" - integrity sha512-tTMeLCLqSBqnflBZnlVDhpaIMucSGaYyX6855jM4AguGeWCeSzNdb1mfyWduTZ3pe3SJVvVWGL0jO1iKZVPfTA== + 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" @@ -13736,13 +14697,20 @@ hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== -iconv-lite@0.4.24, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-replace-symbols@1.1.0, icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -13762,12 +14730,12 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@^1.1.13, ieee754@^1.1.4: +ieee754@1.1.13: 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.2.1: +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" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -13782,7 +14750,7 @@ ignore-by-default@^1.0.1: resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= -ignore-walk@^3.0.1: +ignore-walk@^3.0.1, ignore-walk@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== @@ -13809,10 +14777,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" @@ -13945,22 +14913,22 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - 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.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -init-package-json@^1.10.3: - version "1.10.3" - resolved "https://registry.npmjs.org/init-package-json/-/init-package-json-1.10.3.tgz#45ffe2f610a8ca134f2bd1db5637b235070f6cbe" - integrity sha512-zKSiXKhQveNteyhcj1CoOP8tqp1QuxPIPBl8Bid99DGLFqA1p87M6lNgfjJHSBoWJJlidGOv5rWjyYKEB3g2Jw== +init-package-json@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/init-package-json/-/init-package-json-2.0.2.tgz#d81a7e6775af9b618f20bba288e440b8d1ce05f3" + integrity sha512-PO64kVeArePvhX7Ff0jVWkpnE1DfGRvaWcStYrPugcJz9twQGYibagKJuIMHCX7ENcp0M6LJlcjLBuLD5KeJMg== dependencies: glob "^7.1.1" - npm-package-arg "^4.0.0 || ^5.0.0 || ^6.0.0" + npm-package-arg "^8.1.0" promzard "^0.3.0" read "~1.0.1" - read-package-json "1 || 2" - semver "2.x || 3.x || 4 || 5" - validate-npm-package-license "^3.0.1" + read-package-json "^3.0.0" + semver "^7.3.2" + validate-npm-package-license "^3.0.4" validate-npm-package-name "^3.0.0" inline-style-prefixer@^4.0.0: @@ -13990,25 +14958,6 @@ inquirer@7.0.4: strip-ansi "^5.1.0" through "^2.3.6" -inquirer@^6.2.0: - version "6.5.2" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" - integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ== - dependencies: - ansi-escapes "^3.2.0" - chalk "^2.4.2" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^3.0.3" - figures "^2.0.0" - lodash "^4.17.12" - mute-stream "0.0.7" - run-async "^2.2.0" - rxjs "^6.4.0" - string-width "^2.1.0" - strip-ansi "^5.1.0" - through "^2.3.6" - inquirer@^7.0.0, inquirer@^7.0.4, inquirer@^7.3.3: version "7.3.3" resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" @@ -14055,7 +15004,7 @@ interpret@^2.0.0, interpret@^2.2.0: resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -invariant@^2.0.0, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.0.0, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -14072,7 +15021,7 @@ ip-regex@^2.1.0: resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= -ip@1.1.5, ip@^1.1.0, ip@^1.1.5: +ip@^1.1.0, ip@^1.1.5: version "1.1.5" resolved "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= @@ -14250,9 +15199,9 @@ is-directory@^0.3.1: integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= is-docker@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b" - integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ== + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" + integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== is-dom@^1.0.9, is-dom@^1.1.0: version "1.1.0" @@ -14330,7 +15279,7 @@ is-glob@^2.0.0: dependencies: is-extglob "^1.0.0" -is-glob@^3.1.0: +is-glob@^3.0.0, is-glob@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= @@ -14360,6 +15309,11 @@ is-interactive@^1.0.0: resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= + is-map@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1" @@ -14392,7 +15346,7 @@ is-number@^7.0.0: resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-obj@^1.0.0, is-obj@^1.0.1: +is-obj@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= @@ -14462,6 +15416,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" @@ -14612,11 +15571,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" @@ -14629,7 +15583,7 @@ isarray@^2.0.5: isexe@^2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= isobject@^2.0.0: @@ -14664,6 +15618,23 @@ isomorphic-form-data@~2.0.0: dependencies: form-data "^2.3.2" +isomorphic-git@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.8.0.tgz#50440650a64706a321cbea1af955c1cf1110b238" + integrity sha512-TWJvQh+++eFrEG0IFS/jLhMwsBoCOX1/Dsw9q8no59Mp1K0jEjSHXFWv2P04PwkxcIpePkXVBI5YFcFT2nkuQg== + dependencies: + async-lock "^1.1.0" + clean-git-ref "^2.0.1" + crc-32 "^1.2.0" + diff3 "0.0.3" + ignore "^5.1.4" + minimisted "^2.0.0" + pako "^1.0.10" + pify "^4.0.1" + readable-stream "^3.4.0" + sha.js "^2.4.9" + simple-get "^3.0.2" + isomorphic-ws@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" @@ -14715,7 +15686,7 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -iterall@^1.1.3, iterall@^1.2.1, iterall@^1.2.2, iterall@^1.3.0: +iterall@^1.1.3, iterall@^1.2.1, iterall@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== @@ -14744,63 +15715,63 @@ 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" -jest-changed-files@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.5.2.tgz#330232c6a5c09a7f040a5870e8f0a9c6abcdbed5" - integrity sha512-qSmssmiIdvM5BWVtyK/nqVpN3spR5YyvkvPqz1x3BR1bwIxsWmU/MGwLoCrPNLbkG2ASAKfvmJpOduEApBPh2w== +jest-changed-files@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" + integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" execa "^4.0.0" throat "^5.0.0" -jest-cli@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.5.3.tgz#f936b98f247b76b7bc89c7af50af82c88e356a80" - integrity sha512-HkbSvtugpSXBf2660v9FrNVUgxvPkssN8CRGj9gPM8PLhnaa6zziFiCEKQAkQS4uRzseww45o0TR+l6KeRYV9A== +jest-cli@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" + integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== dependencies: - "@jest/core" "^26.5.3" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/core" "^26.6.3" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-config "^26.6.3" + jest-util "^26.6.2" + jest-validate "^26.6.2" prompts "^2.0.1" yargs "^15.4.1" -jest-config@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.5.3.tgz#baf51c9be078c2c755c8f8a51ec0f06c762c1d3f" - integrity sha512-NVhZiIuN0GQM6b6as4CI5FSCyXKxdrx5ACMCcv/7Pf+TeCajJhJc+6dwgdAVPyerUFB9pRBIz3bE7clSrRge/w== +jest-config@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" + integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^26.5.3" - "@jest/types" "^26.5.2" - babel-jest "^26.5.2" + "@jest/test-sequencer" "^26.6.3" + "@jest/types" "^26.6.2" + babel-jest "^26.6.3" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" - jest-environment-jsdom "^26.5.2" - jest-environment-node "^26.5.2" + jest-environment-jsdom "^26.6.2" + jest-environment-node "^26.6.2" jest-get-type "^26.3.0" - jest-jasmine2 "^26.5.3" + jest-jasmine2 "^26.6.3" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" micromatch "^4.0.2" - pretty-format "^26.5.2" + pretty-format "^26.6.2" jest-css-modules@^2.1.0: version "2.1.0" @@ -14809,17 +15780,17 @@ jest-css-modules@^2.1.0: dependencies: identity-obj-proxy "3.0.0" -jest-diff@^25.2.1: - version "25.5.0" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" - integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== +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 "^3.0.0" - diff-sequences "^25.2.6" - jest-get-type "^25.2.6" - pretty-format "^25.5.0" + 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, jest-diff@^26.5.2: +jest-diff@^26.0.0: version "26.6.1" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== @@ -14829,6 +15800,16 @@ jest-diff@^26.0.0, jest-diff@^26.5.2: jest-get-type "^26.3.0" pretty-format "^26.6.1" +jest-diff@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" + integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== + dependencies: + chalk "^4.0.0" + diff-sequences "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -14836,143 +15817,160 @@ jest-docblock@^26.0.0: dependencies: detect-newline "^3.0.0" -jest-each@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.5.2.tgz#35e68d6906a7f826d3ca5803cfe91d17a5a34c31" - integrity sha512-w7D9FNe0m2D3yZ0Drj9CLkyF/mGhmBSULMQTypzAKR746xXnjUrK8GUJdlLTWUF6dd0ks3MtvGP7/xNFr9Aphg== +jest-each@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" + integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" jest-get-type "^26.3.0" - jest-util "^26.5.2" - pretty-format "^26.5.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" -jest-environment-jsdom@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.5.2.tgz#5feab05b828fd3e4b96bee5e0493464ddd2bb4bc" - integrity sha512-fWZPx0bluJaTQ36+PmRpvUtUlUFlGGBNyGX1SN3dLUHHMcQ4WseNEzcGGKOw4U5towXgxI4qDoI3vwR18H0RTw== +jest-environment-jsdom@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" + integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== dependencies: - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" jsdom "^16.4.0" -jest-environment-node@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.5.2.tgz#275a0f01b5e47447056f1541a15ed4da14acca03" - integrity sha512-YHjnDsf/GKFCYMGF1V+6HF7jhY1fcLfLNBDjhAOvFGvt6d8vXvNdJGVM7uTZ2VO/TuIyEFhPGaXMX5j3h7fsrA== +jest-environment-node@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" + integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== dependencies: - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.5.2" - jest-util "^26.5.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" -jest-esm-transformer@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/jest-esm-transformer/-/jest-esm-transformer-1.0.0.tgz#b6c58f496aa48194f96361a52f5c578fd2209726" - integrity sha512-FoPgeMMwy1/CEsc8tBI41i83CEO3x85RJuZi5iAMmWoARXhfgk6Jd7y+4d+z+HCkTKNVDvSWKGRhwjzU9PUbrw== - dependencies: - "@babel/core" "^7.4.4" - "@babel/plugin-transform-modules-commonjs" "^7.4.4" - -jest-get-type@^25.2.6: - version "25.2.6" - resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" - integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== +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" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== -jest-haste-map@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.5.2.tgz#a15008abfc502c18aa56e4919ed8c96304ceb23d" - integrity sha512-lJIAVJN3gtO3k4xy+7i2Xjtwh8CfPcH08WYjZpe9xzveDaqGw9fVNCpkYu6M525wKFVkLmyi7ku+DxCAP1lyMA== +jest-haste-map@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" + integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/graceful-fs" "^4.1.2" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" jest-regex-util "^26.0.0" - jest-serializer "^26.5.0" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-serializer "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.5.3.tgz#baad2114ce32d16aff25aeb877d18bb4e332dc4c" - integrity sha512-nFlZOpnGlNc7y/+UkkeHnvbOM+rLz4wB1AimgI9QhtnqSZte0wYjbAm8hf7TCwXlXgDwZxAXo6z0a2Wzn9FoOg== +jest-jasmine2@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" + integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^26.5.2" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/environment" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - expect "^26.5.3" + expect "^26.6.2" is-generator-fn "^2.0.0" - jest-each "^26.5.2" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" - jest-runtime "^26.5.3" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - pretty-format "^26.5.2" + jest-each "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" throat "^5.0.0" -jest-leak-detector@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.5.2.tgz#83fcf9a4a6ef157549552cb4f32ca1d6221eea69" - integrity sha512-h7ia3dLzBFItmYERaLPEtEKxy3YlcbcRSjj0XRNJgBEyODuu+3DM2o62kvIFvs3PsaYoIIv+e+nLRI61Dj1CNw== +jest-leak-detector@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" + integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== dependencies: jest-get-type "^26.3.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-matcher-utils@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.5.2.tgz#6aa2c76ce8b9c33e66f8856ff3a52bab59e6c85a" - integrity sha512-W9GO9KBIC4gIArsNqDUKsLnhivaqf8MSs6ujO/JDcPIQrmY+aasewweXVET8KdrJ6ADQaUne5UzysvF/RR7JYA== +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" + integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== dependencies: chalk "^4.0.0" - jest-diff "^26.5.2" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-message-util@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.5.2.tgz#6c4c4c46dcfbabb47cd1ba2f6351559729bc11bb" - integrity sha512-Ocp9UYZ5Jl15C5PNsoDiGEk14A4NG0zZKknpWdZGoMzJuGAkVt10e97tnEVMYpk7LnQHZOfuK2j/izLBMcuCZw== +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/types" "^26.5.2" + "@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" + integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/types" "^26.6.2" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.4" micromatch "^4.0.2" + pretty-format "^26.6.2" slash "^3.0.0" stack-utils "^2.0.2" -jest-mock@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.5.2.tgz#c9302e8ef807f2bfc749ee52e65ad11166a1b6a1" - integrity sha512-9SiU4b5PtO51v0MtJwVRqeGEroH66Bnwtq4ARdNP7jNXbpT7+ByeWNAk4NeT/uHfNSVDXEXgQo1XRuwEqS6Rdw== +jest-mock@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" + integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/node" "*" jest-pnp-resolver@^1.2.2: @@ -14980,123 +15978,129 @@ 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" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== -jest-resolve-dependencies@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.5.3.tgz#11483f91e534bdcd257ab21e8622799e59701aba" - integrity sha512-+KMDeke/BFK+mIQ2IYSyBz010h7zQaVt4Xie6cLqUGChorx66vVeQVv4ErNoMwInnyYHi1Ud73tDS01UbXbfLQ== +jest-resolve-dependencies@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" + integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" jest-regex-util "^26.0.0" - jest-snapshot "^26.5.3" + jest-snapshot "^26.6.2" -jest-resolve@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.5.2.tgz#0d719144f61944a428657b755a0e5c6af4fc8602" - integrity sha512-XsPxojXGRA0CoDD7Vis59ucz2p3cQFU5C+19tz3tLEAlhYKkK77IL0cjYjikY9wXnOaBeEdm1rOgSJjbZWpcZg== +jest-resolve@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" + integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.2" - jest-util "^26.5.2" + jest-util "^26.6.2" read-pkg-up "^7.0.1" - resolve "^1.17.0" + resolve "^1.18.1" slash "^3.0.0" -jest-runner@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.5.3.tgz#800787459ea59c68e7505952933e33981dc3db38" - integrity sha512-qproP0Pq7IIule+263W57k2+8kWCszVJTC9TJWGUz0xJBr+gNiniGXlG8rotd0XxwonD5UiJloYoSO5vbUr5FQ== +jest-runner@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" + integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== dependencies: - "@jest/console" "^26.5.2" - "@jest/environment" "^26.5.2" - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" - jest-config "^26.5.3" + jest-config "^26.6.3" jest-docblock "^26.0.0" - jest-haste-map "^26.5.2" - jest-leak-detector "^26.5.2" - jest-message-util "^26.5.2" - jest-resolve "^26.5.2" - jest-runtime "^26.5.3" - jest-util "^26.5.2" - jest-worker "^26.5.0" + jest-haste-map "^26.6.2" + jest-leak-detector "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + jest-runtime "^26.6.3" + jest-util "^26.6.2" + jest-worker "^26.6.2" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.5.3.tgz#5882ae91fd88304310f069549e6bf82f3f198bea" - integrity sha512-IDjalmn2s/Tc4GvUwhPHZ0iaXCdMRq5p6taW9P8RpU+FpG01O3+H8z+p3rDCQ9mbyyyviDgxy/LHPLzrIOKBkQ== +jest-runtime@^26.6.3: + version "26.6.3" + resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" + integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== dependencies: - "@jest/console" "^26.5.2" - "@jest/environment" "^26.5.2" - "@jest/fake-timers" "^26.5.2" - "@jest/globals" "^26.5.3" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.5.2" - "@jest/transform" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/globals" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/yargs" "^15.0.0" chalk "^4.0.0" + cjs-module-lexer "^0.6.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" - jest-config "^26.5.3" - jest-haste-map "^26.5.2" - jest-message-util "^26.5.2" - jest-mock "^26.5.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.5.2" - jest-snapshot "^26.5.3" - jest-util "^26.5.2" - jest-validate "^26.5.3" + jest-resolve "^26.6.2" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" slash "^3.0.0" strip-bom "^4.0.0" yargs "^15.4.1" -jest-serializer@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.5.0.tgz#f5425cc4c5f6b4b355f854b5f0f23ec6b962bc13" - integrity sha512-+h3Gf5CDRlSLdgTv7y0vPIAoLgX/SI7T4v6hy+TEXMgYbv+ztzbg5PSN6mUXAT/hXYHvZRWm+MaObVfqkhCGxA== +jest-serializer@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" + integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== dependencies: "@types/node" "*" graceful-fs "^4.2.4" -jest-snapshot@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.5.3.tgz#f6b4b4b845f85d4b0dadd7cf119c55d0c1688601" - integrity sha512-ZgAk0Wm0JJ75WS4lGaeRfa0zIgpL0KD595+XmtwlIEMe8j4FaYHyZhP1LNOO+8fXq7HJ3hll54+sFV9X4+CGVw== +jest-snapshot@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" + integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" "@types/babel__traverse" "^7.0.4" "@types/prettier" "^2.0.0" chalk "^4.0.0" - expect "^26.5.3" + expect "^26.6.2" graceful-fs "^4.2.4" - jest-diff "^26.5.2" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - jest-haste-map "^26.5.2" - jest-matcher-utils "^26.5.2" - jest-message-util "^26.5.2" - jest-resolve "^26.5.2" + jest-haste-map "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" natural-compare "^1.4.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" semver "^7.3.2" -jest-util@^26.1.0, jest-util@^26.5.2: +jest-util@^26.1.0: version "26.6.1" resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.1.tgz#4cc0d09ec57f28d12d053887eec5dc976a352e9b" integrity sha512-xCLZUqVoqhquyPLuDXmH7ogceGctbW8SMyQVjD9o+1+NPWI7t0vO08udcFLVPLgKWcvc+zotaUv/RuaR6l8HIA== @@ -15108,31 +16112,51 @@ jest-util@^26.1.0, jest-util@^26.5.2: is-ci "^2.0.0" micromatch "^4.0.2" -jest-validate@^26.5.3: - version "26.5.3" - resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.5.3.tgz#eefd5a5c87059550548c5ad8d6589746c66929e3" - integrity sha512-LX07qKeAtY+lsU0o3IvfDdN5KH9OulEGOMN1sFo6PnEf5/qjS1LZIwNk9blcBeW94pQUI9dLN9FlDYDWI5tyaA== +jest-util@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" + integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== dependencies: - "@jest/types" "^26.5.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + graceful-fs "^4.2.4" + is-ci "^2.0.0" + micromatch "^4.0.2" + +jest-validate@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" + integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== + dependencies: + "@jest/types" "^26.6.2" camelcase "^6.0.0" chalk "^4.0.0" jest-get-type "^26.3.0" leven "^3.1.0" - pretty-format "^26.5.2" + pretty-format "^26.6.2" -jest-watcher@^26.5.2: - version "26.5.2" - resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.5.2.tgz#2957f4461007e0769d74b537379ecf6b7c696916" - integrity sha512-i3m1NtWzF+FXfJ3ljLBB/WQEp4uaNhX7QcQUWMokcifFTUQBDFyUMEwk0JkJ1kopHbx7Een3KX0Q7+9koGM/Pw== +jest-watcher@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" + integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== dependencies: - "@jest/test-result" "^26.5.2" - "@jest/types" "^26.5.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - jest-util "^26.5.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" @@ -15142,35 +16166,40 @@ jest-worker@^26.2.1: merge-stream "^2.0.0" supports-color "^7.0.0" -jest-worker@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.5.0.tgz#87deee86dbbc5f98d9919e0dadf2c40e3152fa30" - integrity sha512-kTw66Dn4ZX7WpjZ7T/SUDgRhapFRKWmisVAF0Rv4Fu8SLFD7eLbqpLvbxVqYhSgaWa7I+bW7pHnbyfNsH6stug== +jest-worker@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== dependencies: "@types/node" "*" merge-stream "^2.0.0" supports-color "^7.0.0" jest@^26.0.1: - version "26.5.3" - resolved "https://registry.npmjs.org/jest/-/jest-26.5.3.tgz#5e7a322d16f558dc565ca97639e85993ef5affe6" - integrity sha512-uJi3FuVSLmkZrWvaDyaVTZGLL8WcfynbRnFXyAHuEtYiSZ+ijDDIMOw1ytmftK+y/+OdAtsG9QrtbF7WIBmOyA== + version "26.6.3" + resolved "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" + integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== dependencies: - "@jest/core" "^26.5.3" + "@jest/core" "^26.6.3" import-local "^3.0.2" - jest-cli "^26.5.3" + jest-cli "^26.6.3" + +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.npmjs.org/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" @@ -15212,6 +16241,13 @@ js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.6.1, js-yaml@^3.8. argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -15291,6 +16327,13 @@ jsesc@~0.5.0: resolved "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + json-buffer@3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" @@ -15301,11 +16344,16 @@ json-buffer@3.0.1: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-parse-better-errors@^1.0.0, json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: +json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + json-pointer@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.0.tgz#8e500550a6aac5464a473377da57aa6cc22828d7" @@ -15343,6 +16391,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" @@ -15391,7 +16444,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.0, 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== @@ -15433,7 +16486,7 @@ jsonify@~0.0.0: resolved "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= -jsonparse@^1.2.0: +jsonparse@^1.2.0, jsonparse@^1.3.1: version "1.3.1" resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= @@ -15582,6 +16635,11 @@ jsx-ast-utils@^2.2.1: array-includes "^3.1.1" object.assign "^4.1.1" +junk@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" + integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -15591,6 +16649,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -15599,11 +16666,32 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + jwt-decode@*, jwt-decode@^3.1.0: version "3.1.2" 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" @@ -15649,7 +16737,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== @@ -15666,23 +16754,27 @@ kleur@^3.0.3: resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +klona@^2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/klona/-/klona-2.0.4.tgz#7bb1e3affb0cb8624547ef7e8f6708ea2e39dfc0" + integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA== + knex@^0.21.6: - version "0.21.8" - resolved "https://registry.npmjs.org/knex/-/knex-0.21.8.tgz#e5c07af61ee6aa006d3468e10e3a69351deb0c26" - integrity sha512-ziUu4vAlIGA8j2l0S4xcD1d3XdpJA4HYGhwHEhgAgefGCmB1OLSjUGCs/ebkJal42fSvAkyZaB0tcOtTXKgS5g== + version "0.21.18" + resolved "https://registry.npmjs.org/knex/-/knex-0.21.18.tgz#ca16e95b8b5c0891a6b012fb3fb34185e067b0e2" + integrity sha512-hP4Qyy2LjQLsabQYc6YgDFHb+CJJBjQz1TMPTKyN85CF2NLIsZsF4FDe0NvQXeXMeak3E/kKr5iJog1JBm6ZCQ== dependencies: colorette "1.2.1" - commander "^5.1.0" - debug "4.1.1" + commander "^6.2.0" + debug "4.3.1" esm "^3.2.25" getopts "2.2.5" interpret "^2.2.0" liftoff "3.1.0" lodash "^4.17.20" - pg-connection-string "2.3.0" + pg-connection-string "2.4.0" tarn "^3.0.1" tildify "2.0.0" - uuid "^7.0.3" v8flags "^3.2.0" kuler@1.0.x: @@ -15765,28 +16857,28 @@ left-pad@^1.3.0: resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== -lerna@^3.20.2: - version "3.22.1" - resolved "https://registry.npmjs.org/lerna/-/lerna-3.22.1.tgz#82027ac3da9c627fd8bf02ccfeff806a98e65b62" - integrity sha512-vk1lfVRFm+UuEFA7wkLKeSF7Iz13W+N/vFd48aW2yuS7Kv0RbNm2/qcDPV863056LMfkRlsEe+QYOw3palj5Lg== +lerna@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/lerna/-/lerna-4.0.0.tgz#b139d685d50ea0ca1be87713a7c2f44a5b678e9e" + integrity sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg== dependencies: - "@lerna/add" "3.21.0" - "@lerna/bootstrap" "3.21.0" - "@lerna/changed" "3.21.0" - "@lerna/clean" "3.21.0" - "@lerna/cli" "3.18.5" - "@lerna/create" "3.22.0" - "@lerna/diff" "3.21.0" - "@lerna/exec" "3.21.0" - "@lerna/import" "3.22.0" - "@lerna/info" "3.21.0" - "@lerna/init" "3.21.0" - "@lerna/link" "3.21.0" - "@lerna/list" "3.21.0" - "@lerna/publish" "3.22.1" - "@lerna/run" "3.21.0" - "@lerna/version" "3.22.1" - import-local "^2.0.0" + "@lerna/add" "4.0.0" + "@lerna/bootstrap" "4.0.0" + "@lerna/changed" "4.0.0" + "@lerna/clean" "4.0.0" + "@lerna/cli" "4.0.0" + "@lerna/create" "4.0.0" + "@lerna/diff" "4.0.0" + "@lerna/exec" "4.0.0" + "@lerna/import" "4.0.0" + "@lerna/info" "4.0.0" + "@lerna/init" "4.0.0" + "@lerna/link" "4.0.0" + "@lerna/list" "4.0.0" + "@lerna/publish" "4.0.0" + "@lerna/run" "4.0.0" + "@lerna/version" "4.0.0" + import-local "^3.0.2" npmlog "^4.1.2" leven@^3.1.0: @@ -15794,13 +16886,6 @@ leven@^3.1.0: resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== -levenary@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" - integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== - dependencies: - leven "^3.1.0" - levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -15822,6 +16907,27 @@ li@^1.3.0: resolved "https://registry.npmjs.org/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" integrity sha1-IsWbyu+qmo7zWc91l4TkvxBq6hs= +libnpmaccess@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/libnpmaccess/-/libnpmaccess-4.0.1.tgz#17e842e03bef759854adf6eb6c2ede32e782639f" + integrity sha512-ZiAgvfUbvmkHoMTzdwmNWCrQRsDkOC+aM5BDfO0C9aOSwF3R1LdFDBD+Rer1KWtsoQYO35nXgmMR7OUHpDRxyA== + dependencies: + aproba "^2.0.0" + minipass "^3.1.1" + npm-package-arg "^8.0.0" + npm-registry-fetch "^9.0.0" + +libnpmpublish@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/libnpmpublish/-/libnpmpublish-4.0.0.tgz#ad6413914e0dfd78df868ce14ba3d3a4cc8b385b" + integrity sha512-2RwYXRfZAB1x/9udKpZmqEzSqNd7ouBRU52jyG14/xG8EF+O9A62d7/XVR3iABEQHf1iYhkm0Oq9iXjrL3tsXA== + dependencies: + normalize-package-data "^3.0.0" + npm-package-arg "^8.1.0" + npm-registry-fetch "^9.0.0" + semver "^7.1.3" + ssri "^8.0.0" + liftoff@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" @@ -15970,16 +17076,15 @@ load-json-file@^4.0.0: pify "^3.0.0" strip-bom "^3.0.0" -load-json-file@^5.3.0: - version "5.3.0" - resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz#4d3c1e01fa1c03ea78a60ac7af932c9ce53403f3" - integrity sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw== +load-json-file@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-6.2.0.tgz#5c7770b42cafa97074ca2848707c61662f4251a1" + integrity sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ== dependencies: graceful-fs "^4.1.15" - parse-json "^4.0.0" - pify "^4.0.1" - strip-bom "^3.0.0" - type-fest "^0.3.0" + parse-json "^5.0.0" + strip-bom "^4.0.0" + type-fest "^0.6.0" load-yaml-file@^0.2.0: version "0.2.0" @@ -16103,11 +17208,6 @@ lodash.flattendeep@^4.0.0: resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= - lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -16153,11 +17253,6 @@ lodash.once@^4.0.0, lodash.once@^4.1.1: resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash.set@^4.3.2: - version "4.3.2" - resolved "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" - integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -16168,7 +17263,7 @@ lodash.startcase@^4.4.0: resolved "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz#9436e34ed26093ed7ffae1936144350915d9add8" integrity sha1-lDbjTtJgk+1/+uGTYUQ1CRXZrdg= -lodash.template@^4.0.2, lodash.template@^4.5.0: +lodash.template@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== @@ -16208,7 +17303,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.20, lodash@~4.17.4: +lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.20, lodash@~4.17.4: version "4.17.20" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -16220,13 +17315,6 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-symbols@^2.1.0: - version "2.2.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== - dependencies: - chalk "^2.0.1" - log-symbols@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" @@ -16276,14 +17364,6 @@ loglevel@^1.6.7, loglevel@^1.6.8: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== -loglevelnext@^1.0.1: - version "1.0.5" - resolved "https://registry.npmjs.org/loglevelnext/-/loglevelnext-1.0.5.tgz#36fc4f5996d6640f539ff203ba819641680d75a2" - integrity sha512-V/73qkPuJmx4BcBF19xPBr+0ZRVBhc4POxvZTZdMeXpJ4NItXSJ/MSwuFT0kQJlCbXvdlZoQQ/418bS1y9Jh6A== - dependencies: - es6-symbol "^3.1.1" - object.assign "^4.1.0" - long@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -16294,7 +17374,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== @@ -16316,6 +17396,13 @@ lower-case@2.0.1, lower-case@^2.0.1: dependencies: tslib "^1.10.0" +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -16326,14 +17413,6 @@ lowercase-keys@^2.0.0: resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lowlight@1.12.1: - version "1.12.1" - resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.12.1.tgz#014acf8dd73a370e02ff1cc61debcde3bb1681eb" - integrity sha512-OqaVxMGIESnawn+TU/QMV5BJLbUghUfjDWPAtFqDYDmDtr4FnB+op8xM+pR7nKlauHNUHXGt0VgWatFB8voS5w== - dependencies: - fault "^1.0.2" - highlight.js "~9.15.0" - lowlight@^1.14.0: version "1.14.0" resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.14.0.tgz#83ebc143fec0f9e6c0d3deffe01be129ce56b108" @@ -16342,6 +17421,14 @@ lowlight@^1.14.0: fault "^1.0.0" highlight.js "~10.1.0" +lowlight@^1.17.0: + version "1.17.0" + resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.17.0.tgz#a1143b2fba8239df8cd5893f9fe97aaf8465af4a" + integrity sha512-vmtBgYKD+QVNy7tIa7ulz5d//Il9R4MooOVh4nkOf9R9Cb/Dk5TXMSTieg/vDulkBkIWj59/BIlyFQxT9X1oAQ== + dependencies: + fault "^1.0.0" + highlight.js "~10.4.0" + lru-cache@^4.0.1: version "4.1.5" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -16359,7 +17446,7 @@ lru-cache@^5.0.0, lru-cache@^5.1.1: lru-cache@^6.0.0: version "6.0.0" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== dependencies: yallist "^4.0.0" @@ -16371,6 +17458,21 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +luxon@1.25.0, luxon@^1.25.0: + version "1.25.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" + integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== + +luxon@^1.26.0: + version "1.26.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-1.26.0.tgz#d3692361fda51473948252061d0f8561df02b578" + integrity sha512-+V5QIQ5f6CDXQpWNICELwjwuHdqeJM1UenlZWx5ujcRMc9venvluCjFb4t5NYLhb6IhkbMVOxzVuOqkgMxee2A== + +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" @@ -16383,13 +17485,6 @@ magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.4" -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -16410,22 +17505,26 @@ make-error@1.x, make-error@^1.1.1, make-error@^1.3.6: resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== -make-fetch-happen@^5.0.0: - version "5.0.2" - resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-5.0.2.tgz#aa8387104f2687edca01c8687ee45013d02d19bd" - integrity sha512-07JHC0r1ykIoruKO8ifMXu+xEU8qOXDFETylktdug6vJDACnP+HKevOu3PXyNPzFyTSlz8vrBYlBO1JZRe8Cag== +make-fetch-happen@^8.0.9: + version "8.0.14" + resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz#aaba73ae0ab5586ad8eaa68bd83332669393e222" + integrity sha512-EsS89h6l4vbfJEtBZnENTOFk8mCRpY5ru36Xe5bcX1KYIli2mkSHqoFsp5O1wMDvTJJzxe/4THpCTtygjeeGWQ== dependencies: - agentkeepalive "^3.4.1" - cacache "^12.0.0" - http-cache-semantics "^3.8.1" - http-proxy-agent "^2.1.0" - https-proxy-agent "^2.2.3" - lru-cache "^5.1.1" - mississippi "^3.0.0" - node-fetch-npm "^2.0.2" - promise-retry "^1.1.1" - socks-proxy-agent "^4.0.0" - ssri "^6.0.0" + agentkeepalive "^4.1.3" + cacache "^15.0.5" + http-cache-semantics "^4.1.0" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^6.0.0" + minipass "^3.1.3" + minipass-collect "^1.0.2" + minipass-fetch "^1.3.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + promise-retry "^2.0.1" + socks-proxy-agent "^5.0.0" + ssri "^8.0.0" make-iterator@^1.0.0: version "1.0.1" @@ -16451,11 +17550,6 @@ map-obj@^1.0.0, map-obj@^1.0.1: resolved "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= -map-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9" - integrity sha1-plzSkIepJZi4eRJXpSPgISIqwfk= - map-obj@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5" @@ -16701,35 +17795,39 @@ meow@^3.3.0: redent "^1.0.0" trim-newlines "^1.0.0" -meow@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz#d48598f6f4b1472f35bf6317a95945ace347f975" - integrity sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A== +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: - camelcase-keys "^4.0.0" - decamelize-keys "^1.0.0" - loud-rejection "^1.0.0" - minimist "^1.1.3" - minimist-options "^3.0.1" - normalize-package-data "^2.3.4" - read-pkg-up "^3.0.0" - redent "^2.0.0" - trim-newlines "^2.0.0" + "@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" -meow@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" - integrity sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig== +meow@^8.0.0: + version "8.1.2" + resolved "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" + integrity sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q== dependencies: - camelcase-keys "^4.0.0" - decamelize-keys "^1.0.0" - loud-rejection "^1.0.0" - minimist-options "^3.0.1" - normalize-package-data "^2.3.4" - read-pkg-up "^3.0.0" - redent "^2.0.0" - trim-newlines "^2.0.0" - yargs-parser "^10.0.0" + "@types/minimist" "^1.2.0" + camelcase-keys "^6.2.2" + decamelize-keys "^1.1.0" + hard-rejection "^2.1.0" + minimist-options "4.1.0" + normalize-package-data "^3.0.0" + read-pkg-up "^7.0.1" + redent "^3.0.0" + trim-newlines "^3.0.0" + type-fest "^0.18.0" + yargs-parser "^20.2.3" merge-deep@^3.0.2: version "3.0.2" @@ -16755,10 +17853,15 @@ 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== + +meros@^1.1.2: + version "1.1.4" + resolved "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz#c17994d3133db8b23807f62bec7f0cb276cfd948" + integrity sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ== methods@^1.0.0, methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" @@ -16866,7 +17969,7 @@ mime-db@1.44.0, "mime-db@>= 1.43.0 < 2": resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== -mime-types@^2.1.12, mime-types@^2.1.26, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== @@ -16878,6 +17981,11 @@ mime@1.6.0, mime@^1.4.1: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^2.2.0: + version "2.4.6" + resolved "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" + integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + mime@^2.3.1, mime@^2.4.4: version "2.4.4" resolved "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" @@ -16898,7 +18006,7 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== -mimic-response@^2.0.0, mimic-response@^2.1.0: +mimic-response@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== @@ -16920,14 +18028,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" @@ -16948,26 +18048,34 @@ 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== dependencies: brace-expansion "^1.1.7" -minimist-options@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" - integrity sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ== +minimist-options@4.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" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimisted@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz#d059fb905beecf0774bc3b308468699709805cb1" + integrity sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA== + dependencies: + minimist "^1.2.5" + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" @@ -16975,6 +18083,17 @@ minipass-collect@^1.0.2: dependencies: minipass "^3.0.0" +minipass-fetch@^1.3.0, minipass-fetch@^1.3.2: + version "1.3.3" + resolved "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.3.3.tgz#34c7cea038c817a8658461bf35174551dce17a0a" + integrity sha512-akCrLDWfbdAWkMLBxJEeWTdNsjML+dt5YgOI4gJ53vuO0vrmYQkUPxa6j6V65s9CcePIr2SSWqjT2EcrNseryQ== + dependencies: + minipass "^3.1.0" + minipass-sized "^1.0.3" + minizlib "^2.0.0" + optionalDependencies: + encoding "^0.1.12" + minipass-flush@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" @@ -16982,6 +18101,14 @@ minipass-flush@^1.0.5: dependencies: minipass "^3.0.0" +minipass-json-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" + integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== + dependencies: + jsonparse "^1.3.1" + minipass "^3.0.0" + minipass-pipeline@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.2.tgz#3dcb6bb4a546e32969c7ad710f2c79a86abba93a" @@ -16989,7 +18116,21 @@ minipass-pipeline@^1.2.2: dependencies: minipass "^3.0.0" -minipass@^2.3.5, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: version "2.9.0" resolved "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== @@ -17004,6 +18145,13 @@ minipass@^3.0.0, minipass@^3.1.1: dependencies: yallist "^4.0.0" +minipass@^3.1.0, minipass@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + dependencies: + yallist "^4.0.0" + minizlib@^1.2.1: version "1.3.3" resolved "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" @@ -17011,7 +18159,7 @@ minizlib@^1.2.1: dependencies: minipass "^2.9.0" -minizlib@^2.1.1: +minizlib@^2.0.0, minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== @@ -17066,14 +18214,16 @@ mkdirp-classic@^0.5.2: resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz#54c441ce4c96cd7790e10b41a87aa51068ecab2b" integrity sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g== -mkdirp-promise@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/mkdirp-promise/-/mkdirp-promise-5.0.1.tgz#e9b8f68e552c68a9c1713b84883f7a1dd039b8a1" - integrity sha1-6bj2jlUsaKnBcTuEiD96HdA5uKE= +mkdirp-infer-owner@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/mkdirp-infer-owner/-/mkdirp-infer-owner-2.0.0.tgz#55d3b368e7d89065c38f32fd38e638f0ab61d316" + integrity sha512-sdqtiFt3lkOaYvTXSRIUjkIdPTcxgv5+fgqYE/5qgwdw12cOrAuzzgzvVExIkH/ul1oeHN3bCLOWSG3XOqbKKw== dependencies: - mkdirp "*" + chownr "^2.0.0" + infer-owner "^1.0.4" + mkdirp "^1.0.3" -mkdirp@*, mkdirp@1.x, mkdirp@^1.0.3, mkdirp@^1.0.4: +mkdirp@1.x, mkdirp@^1.0.3, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== @@ -17095,25 +18245,11 @@ 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.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== -monaco-editor@^0.20.0: - version "0.20.0" - resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea" - integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ== - -monaco-graphql@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-0.2.0.tgz#be6bd165b6cbdc624fde6c108fe9ee32f50f2e80" - integrity sha512-IwpbJvrNlFxq6xjnQQ8eC72ep+q2vfiUaHBNkfqe314CXYnUCcXYfoMBfR5EuLAXRBOTf4CKl6BCN1/9eHrq7A== - dependencies: - graphql-language-service "^3.0.0" - graphql-language-service-utils "^2.4.0" - monaco-editor "^0.20.0" - morgan@^1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" @@ -17152,6 +18288,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" @@ -17218,27 +18361,32 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" thunky "^1.0.2" -multimatch@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/multimatch/-/multimatch-3.0.0.tgz#0e2534cc6bc238d9ab67e1b9cd5fcd85a6dbf70b" - integrity sha512-22foS/gqQfANZ3o+W7ST2x25ueHDVNWl/b9OlGcLpy/iKxjCpvcNCM51YCenUi7Mt/jAjjqv8JwZRs8YP5sRjA== +multimatch@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" + integrity sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA== dependencies: - array-differ "^2.0.3" - array-union "^1.0.2" - arrify "^1.0.1" + "@types/minimatch" "^3.0.3" + array-differ "^3.0.0" + array-union "^2.1.0" + arrify "^2.0.1" minimatch "^3.0.4" -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= - mute-stream@0.0.8, mute-stream@~0.0.4: version "0.0.8" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -mz@^2.5.0, mz@^2.7.0: +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.7.0: version "2.7.0" resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== @@ -17247,7 +18395,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== @@ -17271,6 +18419,11 @@ nanoid@^2.1.0: resolved "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== +nanoid@^3.1.20: + version "3.1.20" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" + integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -17288,11 +18441,28 @@ 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" + integrity sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA== + dependencies: + querystring "^0.2.0" + natural-compare@^1.4.0: version "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" @@ -17312,6 +18482,11 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1: resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== +nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61" + integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug== + next-tick@1: version "1.1.0" resolved "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -17335,21 +18510,33 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" -nock@*, 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== +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== dependencies: - debug "^4.1.0" - json-stringify-safe "^5.0.1" - lodash.set "^4.3.2" - propagate "^2.0.0" + lower-case "^2.0.2" + tslib "^2.0.3" + +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" @@ -17357,16 +18544,7 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" -node-fetch-npm@^2.0.2: - version "2.0.3" - resolved "https://registry.npmjs.org/node-fetch-npm/-/node-fetch-npm-2.0.3.tgz#efae4aacb0500444e449a51fc1467397775ebc38" - integrity sha512-DgwoKEsqLnFZtk3ap7GWBHcHwnUhsNmQqEDcdjfQ8GofLEFJ081NAd4Uin3R7RFZBWVJCwHISw1oaEqPgSLloA== - dependencies: - encoding "^0.1.11" - json-parse-better-errors "^1.0.0" - safe-buffer "^5.1.1" - -node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -17399,23 +18577,6 @@ node-gyp@3.x: tar "^2.0.0" which "1" -node-gyp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-4.0.0.tgz#972654af4e5dd0cd2a19081b4b46fe0442ba6f45" - integrity sha512-2XiryJ8sICNo6ej8d0idXDEMKfVfFK7kekGCtJAuelGsYHQxhj13KTf95swTCN2dZ/4lTfZ84Fu31jqJEEgjWA== - dependencies: - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^4.4.8" - which "1" - node-gyp@^5.0.2: version "5.1.0" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-5.1.0.tgz#8e31260a7af4a2e2f994b0673d4e0b3866156332" @@ -17433,6 +18594,22 @@ node-gyp@^5.0.2: tar "^4.4.12" which "^1.3.1" +node-gyp@^7.1.0: + version "7.1.2" + resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz#21a810aebb187120251c3bcec979af1587b188ae" + integrity sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.3" + nopt "^5.0.0" + npmlog "^4.1.2" + request "^2.88.2" + rimraf "^3.0.2" + semver "^7.3.2" + tar "^6.0.2" + which "^2.0.2" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -17478,9 +18655,9 @@ node-modules-regexp@^1.0.0: integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= node-notifier@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz#a7eee2d51da6d0f7ff5094bc7108c911240c1620" - integrity sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA== + version "8.0.1" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.1.tgz#f86e89bbc925f2b068784b31f382afdc6ca56be1" + integrity sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA== dependencies: growly "^1.3.0" is-wsl "^2.2.0" @@ -17505,27 +18682,16 @@ node-pre-gyp@^0.11.0: semver "^5.3.0" tar "^4" -node-pre-gyp@^0.13.0: - version "0.13.0" - resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.13.0.tgz#df9ab7b68dd6498137717838e4f92a33fc9daa42" - integrity sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.52, node-releases@^1.1.58: 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.npmjs.org/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" + integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== + node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" @@ -17544,25 +18710,10 @@ node-request-interceptor@^0.5.1: debug "^4.1.1" headers-utils "^1.2.0" -nodegit@0.27.0, nodegit@^0.27.0: - version "0.27.0" - resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.27.0.tgz#4e8cc236f60e1c97324a5acff99056fe116a6ebe" - integrity sha512-E9K4gPjWiA0b3Tx5lfWCzG7Cvodi2idl3V5UD2fZrOrHikIfrN7Fc2kWLtMUqqomyoToYJLeIC8IV7xb1CYRLA== - dependencies: - fs-extra "^7.0.0" - got "^10.7.0" - json5 "^2.1.0" - lodash "^4.17.14" - nan "^2.14.0" - node-gyp "^4.0.0" - node-pre-gyp "^0.13.0" - ramda "^0.25.0" - tar-fs "^1.16.3" - nodemon@^2.0.2: - version "2.0.4" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz#55b09319eb488d6394aa9818148c0c2d1c04c416" - integrity sha512-Ltced+hIfTmaS28Zjv1BM552oQ3dbwPqI4+zI0SLgq+wpJhSyqgYude/aZa/3i31VCQWMfXJVxvu86abcam3uQ== + 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" @@ -17572,8 +18723,13 @@ nodemon@^2.0.2: semver "^5.7.1" supports-color "^5.5.0" touch "^3.1.0" - undefsafe "^2.0.2" - update-notifier "^4.0.0" + 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" @@ -17590,6 +18746,13 @@ nopt@^4.0.1: abbrev "1" osenv "^0.1.4" +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + nopt@~1.0.10: version "1.0.10" resolved "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -17597,7 +18760,7 @@ nopt@~1.0.10: dependencies: abbrev "1" -normalize-package-data@^2.0.0, normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.3.5, normalize-package-data@^2.4.0, normalize-package-data@^2.5.0: +normalize-package-data@^2.0.0, normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== @@ -17607,6 +18770,16 @@ normalize-package-data@^2.0.0, normalize-package-data@^2.3.0, normalize-package- semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-package-data@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.0.tgz#1f8a7c423b3d2e85eb36985eaf81de381d01301a" + integrity sha512-6lUjEI0d3v6kFrtgA/lOx4zHCWULXsFNIjHolnZCKCTLA6m/G625cdn3O7eNmT0iD3jfo6HZ9cdImGZwf21prw== + dependencies: + hosted-git-info "^3.0.6" + resolve "^1.17.0" + semver "^7.3.2" + validate-npm-package-license "^3.0.1" + normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -17644,17 +18817,24 @@ normalize-url@^4.1.0: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129" integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== -npm-bundled@^1.0.1: +npm-bundled@^1.0.1, npm-bundled@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== dependencies: npm-normalize-package-bin "^1.0.1" -npm-lifecycle@^3.1.2: - version "3.1.4" - resolved "https://registry.npmjs.org/npm-lifecycle/-/npm-lifecycle-3.1.4.tgz#de6975c7d8df65f5150db110b57cce498b0b604c" - integrity sha512-tgs1PaucZwkxECGKhC/stbEgFyc3TGh2TJcg2CDr6jbvQRdteHNhmMeljRzpe4wgFAXQADoy1cSqqi7mtiAa5A== +npm-install-checks@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-4.0.0.tgz#a37facc763a2fde0497ef2c6d0ac7c3fbe00d7b4" + integrity sha512-09OmyDkNLYwqKPOnbI8exiOZU2GVVmQp7tgez2BPi5OZC8M82elDAps7sxC4l//uSUtotWqoEIDwjRvWH4qz8w== + dependencies: + semver "^7.1.1" + +npm-lifecycle@^3.1.5: + version "3.1.5" + resolved "https://registry.npmjs.org/npm-lifecycle/-/npm-lifecycle-3.1.5.tgz#9882d3642b8c82c815782a12e6a1bfeed0026309" + integrity sha512-lDLVkjfZmvmfvpvBzA4vzee9cn+Me4orq0QF8glbswJVEbIcSNWib7qGOffolysc3teCqbbPZZkzbr3GQZTL1g== dependencies: byline "^5.0.0" graceful-fs "^4.1.15" @@ -17670,17 +18850,16 @@ npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1: resolved "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== -"npm-package-arg@^4.0.0 || ^5.0.0 || ^6.0.0", npm-package-arg@^6.0.0, npm-package-arg@^6.1.0: - version "6.1.1" - resolved "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.1.1.tgz#02168cb0a49a2b75bf988a28698de7b529df5cb7" - integrity sha512-qBpssaL3IOZWi5vEKUKW0cO7kzLeT+EQO9W8RsLOZf76KF9E/K9+wH0C7t06HXPpaH8WH5xF1MExLuCwbTqRUg== +npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-package-arg@^8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-8.1.0.tgz#b5f6319418c3246a1c38e1a8fbaa06231bc5308f" + integrity sha512-/ep6QDxBkm9HvOhOg0heitSd7JHA1U7y1qhhlRlteYYAi9Pdb/ZV7FW5aHpkrpM8+P+4p/jjR8zCyKPBMBjSig== dependencies: - hosted-git-info "^2.7.1" - osenv "^0.1.5" - semver "^5.6.0" + hosted-git-info "^3.0.6" + semver "^7.0.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.6, npm-packlist@^1.4.4: +npm-packlist@^1.1.6: version "1.4.8" resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== @@ -17689,14 +18868,38 @@ npm-packlist@^1.1.6, npm-packlist@^1.4.4: npm-bundled "^1.0.1" npm-normalize-package-bin "^1.0.1" -npm-pick-manifest@^3.0.0: - version "3.0.2" - resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-3.0.2.tgz#f4d9e5fd4be2153e5f4e5f9b7be8dc419a99abb7" - integrity sha512-wNprTNg+X5nf+tDi+hbjdHhM4bX+mKqv6XmPh7B5eG+QY9VARfQPfCEH013H5GqfNj6ee8Ij2fg8yk0mzps1Vw== +npm-packlist@^2.1.4: + version "2.1.4" + resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-2.1.4.tgz#40e96b2b43787d0546a574542d01e066640d09da" + integrity sha512-Qzg2pvXC9U4I4fLnUrBmcIT4x0woLtUgxUi9eC+Zrcv1Xx5eamytGAfbDWQ67j7xOcQ2VW1I3su9smVTIdu7Hw== dependencies: - figgy-pudding "^3.5.1" - npm-package-arg "^6.0.0" - semver "^5.4.1" + glob "^7.1.6" + ignore-walk "^3.0.3" + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + +npm-pick-manifest@^6.0.0: + version "6.1.0" + resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-6.1.0.tgz#2befed87b0fce956790f62d32afb56d7539c022a" + integrity sha512-ygs4k6f54ZxJXrzT0x34NybRlLeZ4+6nECAIbr2i0foTnijtS1TJiyzpqtuUAJOps/hO0tNDr8fRV5g+BtRlTw== + dependencies: + npm-install-checks "^4.0.0" + npm-package-arg "^8.0.0" + semver "^7.0.0" + +npm-registry-fetch@^9.0.0: + version "9.0.0" + resolved "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-9.0.0.tgz#86f3feb4ce00313bc0b8f1f8f69daae6face1661" + integrity sha512-PuFYYtnQ8IyVl6ib9d3PepeehcUeHN9IO5N/iCRhyg9tStQcqGQBRVHmfmMWPDERU3KwZoHFvbJ4FPXPspvzbA== + dependencies: + "@npmcli/ci-detect" "^1.0.0" + lru-cache "^6.0.0" + make-fetch-happen "^8.0.9" + minipass "^3.1.3" + minipass-fetch "^1.3.0" + minipass-json-stream "^1.0.1" + minizlib "^2.0.0" + npm-package-arg "^8.0.0" npm-run-path@^2.0.0: version "2.0.2" @@ -17705,14 +18908,14 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npm-run-path@^4.0.0: +npm-run-path@^4.0.0, npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== 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== @@ -17729,7 +18932,7 @@ nth-check@^1.0.2, nth-check@~1.0.1: dependencies: boolbase "~1.0.0" -nullthrows@^1.1.1: +nullthrows@^1.0.0, nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== @@ -17907,17 +19110,12 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -octokit-pagination-methods@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" - integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ== - oidc-token-hash@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888" integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg== -on-finished@^2.3.0, on-finished@~2.3.0: +on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= @@ -17960,6 +19158,21 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + 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" @@ -17980,7 +19193,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== @@ -17994,20 +19207,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" @@ -18039,7 +19238,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== @@ -18090,7 +19289,7 @@ os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: resolved "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= -osenv@0, osenv@^0.1.4, osenv@^0.1.5: +osenv@0, osenv@^0.1.4: version "0.1.5" resolved "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== @@ -18113,6 +19312,13 @@ overlayscrollbars@^1.10.2: resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.0.tgz#1edb436328133b94877b558f77966d5497ca36a7" integrity sha512-p8oHrMeRAKxXDMPI/EBNITj/zTVHKNnAnM59Im+xnoZUlV07FyTg46wom2286jJlXGGfcPFG/ba5NUiCwWNd4w== +p-all@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz#91419be56b7dee8fe4c5db875d55e0da084244a0" + integrity sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA== + dependencies: + p-map "^2.0.0" + p-any@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/p-any/-/p-any-3.0.0.tgz#79847aeed70b5d3a10ea625296c0c3d2e90a87b9" @@ -18136,7 +19342,7 @@ p-each-series@^2.1.0: resolved "https://registry.npmjs.org/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ== -p-event@^4.0.0: +p-event@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== @@ -18160,7 +19366,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== @@ -18181,6 +19387,13 @@ 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.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== + dependencies: + yocto-queue "^0.1.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -18209,14 +19422,12 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-map-series@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/p-map-series/-/p-map-series-1.0.0.tgz#bf98fe575705658a9e1351befb85ae4c1f07bdca" - integrity sha1-v5j+V1cFZYqeE1G++4WuTB8Hvco= - dependencies: - p-reduce "^1.0.0" +p-map-series@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/p-map-series/-/p-map-series-2.1.0.tgz#7560d4c452d9da0c07e692fdbfe6e2c81a2a91f2" + integrity sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q== -p-map@^2.0.0, p-map@^2.1.0: +p-map@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== @@ -18235,17 +19446,10 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-pipe@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-1.2.0.tgz#4b1a11399a11520a67790ee5a0c1d5881d6befe9" - integrity sha1-SxoROZoRUgpneQ7loMHViB1r7+k= - -p-queue@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/p-queue/-/p-queue-4.0.0.tgz#ed0eee8798927ed6f2c2f5f5b77fdb2061a5d346" - integrity sha512-3cRXXn3/O0o3+eVmUroJPSj/esxoEFIm0ZOno/T+NzG/VZgPOqQ8WKmlNqubSEpZmCIngEy34unkHGg83ZIBmg== - dependencies: - eventemitter3 "^3.1.0" +p-pipe@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" + integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== p-queue@^6.3.0: version "6.4.0" @@ -18255,10 +19459,18 @@ p-queue@^6.3.0: eventemitter3 "^4.0.0" p-timeout "^3.1.0" -p-reduce@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" - integrity sha1-GMKw3ZNqRpClKfgjH1ig/bakffo= +p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-reduce@^2.0.0, p-reduce@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" + integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== p-retry@^3.0.1: version "3.0.1" @@ -18275,7 +19487,7 @@ p-some@^5.0.0: aggregate-error "^3.0.0" p-cancelable "^2.0.0" -p-timeout@^3.1.0: +p-timeout@^3.1.0, p-timeout@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== @@ -18292,12 +19504,12 @@ p-try@^2.0.0: resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -p-waterfall@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/p-waterfall/-/p-waterfall-1.0.0.tgz#7ed94b3ceb3332782353af6aae11aa9fc235bb00" - integrity sha1-ftlLPOszMngjU69qrhGqn8I1uwA= +p-waterfall@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/p-waterfall/-/p-waterfall-2.1.1.tgz#63153a774f472ccdc4eb281cdb2967fcf158b2ee" + integrity sha512-RRTnDb2TBG/epPRI2yYXsimO0v3BXC8Yd3ogr1545IaqKK17VGhbWVeGGN+XfCm/08OK8635nH31c8bATkHuSw== dependencies: - p-reduce "^1.0.0" + p-reduce "^2.0.0" package-json@^6.3.0: version "6.5.0" @@ -18319,7 +19531,32 @@ packet-reader@1.0.0: resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== -pako@~1.0.5: +pacote@^11.2.6: + version "11.2.6" + resolved "https://registry.npmjs.org/pacote/-/pacote-11.2.6.tgz#c0426e5d5c8d33aeea3461a75e1390f1ba78f953" + integrity sha512-xCl++Hb3aBC7LaWMimbO4xUqZVsEbKDVc6KKDIIyAeBYrmMwY1yJC2nES/lsGd8sdQLUosgBxQyuVNncZ2Ru0w== + dependencies: + "@npmcli/git" "^2.0.1" + "@npmcli/installed-package-contents" "^1.0.6" + "@npmcli/promise-spawn" "^1.2.0" + "@npmcli/run-script" "^1.8.2" + cacache "^15.0.5" + chownr "^2.0.0" + fs-minipass "^2.1.0" + infer-owner "^1.0.4" + minipass "^3.1.3" + mkdirp "^1.0.3" + npm-package-arg "^8.0.1" + npm-packlist "^2.1.4" + npm-pick-manifest "^6.0.0" + npm-registry-fetch "^9.0.0" + promise-retry "^2.0.1" + read-package-json-fast "^2.0.1" + rimraf "^3.0.2" + ssri "^8.0.1" + tar "^6.1.0" + +pako@^1.0.10, pako@~1.0.5: version "1.0.11" resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -18365,18 +19602,6 @@ parse-asn1@^5.0.0: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-entities@^1.1.2: - version "1.2.2" - resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50" - integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg== - dependencies: - character-entities "^1.0.0" - character-entities-legacy "^1.0.0" - character-reference-invalid "^1.0.0" - is-alphanumerical "^1.0.0" - is-decimal "^1.0.0" - is-hexadecimal "^1.0.0" - parse-entities@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" @@ -18446,6 +19671,11 @@ parse-path@^4.0.0: is-ssh "^1.3.0" protocols "^1.4.0" +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + parse-url@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" @@ -18479,6 +19709,14 @@ pascal-case@3.1.1, pascal-case@^3.1.1: no-case "^3.0.3" tslib "^1.10.0" +pascal-case@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" + integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" @@ -18568,17 +19806,16 @@ passport-onelogin-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" -passport-saml@^1.3.3: - version "1.3.3" - resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" - integrity sha512-54ecY/A6UEsyCehJws6a+J6THvwtYnGl9cnAUxx5DjsuKgZrDs0tSy58K4hCk1XG/LOcdQSF1TR3xlRXgTULhA== +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-encryption "^1.0.0" - xml2js "0.4.x" + xml-crypto "^2.0.0" + xml-encryption "1.2.1" + xml2js "^0.4.23" xmlbuilder "^11.0.0" xmldom "0.1.x" @@ -18664,13 +19901,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" @@ -18742,12 +19972,7 @@ pg-connection-string@0.1.3, pg-connection-string@^0.1.3: resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= -pg-connection-string@2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz#c13fcb84c298d0bfa9ba12b40dd6c23d946f55d6" - integrity sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w== - -pg-connection-string@^2.3.0, pg-connection-string@^2.4.0: +pg-connection-string@2.4.0, pg-connection-string@^2.3.0, pg-connection-string@^2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10" integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ== @@ -18906,6 +20131,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" @@ -18925,6 +20157,11 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + pn@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" @@ -18949,11 +20186,6 @@ popper.js@1.16.1-lts: resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05" integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA== -popper.js@^1.14.4, popper.js@^1.14.7: - version "1.16.1" - resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" - integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== - portfinder@^1.0.26: version "1.0.28" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" @@ -19370,6 +20602,15 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^5.4.0" +postcss@^8.0.2: + version "8.2.6" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.2.6.tgz#5d69a974543b45f87e464bc4c3e392a97d6be9fe" + integrity sha512-xpB8qYxgPuly166AGlpRjUdEYtmOWx2iCwGmrv4vqZL9YPVviDVPZPRXxnXr6xPZOdxQ9lp3ZBFCRgWJ7LE3Sg== + dependencies: + colorette "^1.2.1" + nanoid "^3.1.20" + source-map "^0.6.1" + postgres-array@~1.0.0: version "1.0.3" resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.3.tgz#c561fc3b266b21451fc6555384f4986d78ec80f5" @@ -19397,6 +20638,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" @@ -19432,7 +20694,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== @@ -19443,9 +20705,9 @@ prettier@^2.0.5, prettier@~2.0.5: integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== pretty-bytes@^5.3.0: - version "5.3.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" - integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== + version "5.5.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" + integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== pretty-error@^2.1.1: version "2.1.1" @@ -19455,17 +20717,17 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^25.2.1, pretty-format@^25.5.0: - version "25.5.0" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" - integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== +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" "^25.5.0" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^16.12.0" + "@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.5.2, pretty-format@^26.6.1: +pretty-format@^26.0.0, pretty-format@^26.6.1: version "26.6.1" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== @@ -19475,26 +20737,41 @@ pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-form ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= -prismjs@^1.21.0, prismjs@^1.8.4, prismjs@~1.21.0: +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + +prismjs@^1.21.0, prismjs@~1.21.0: version "1.21.0" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw== optionalDependencies: clipboard "^2.0.0" -prismjs@~1.17.0: - version "1.17.1" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.17.1.tgz#e669fcbd4cdd873c35102881c33b14d0d68519be" - integrity sha512-PrEDJAFdUGbOP6xK/UsfkC5ghJsPJviKgnQOoxaDbBjwc8op68Quupwt1DeAFoG8GImPhiKXAvvsH7wDSLsu1Q== +prismjs@^1.22.0, prismjs@~1.22.0: + version "1.22.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa" + integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w== optionalDependencies: clipboard "^2.0.0" -private@^0.1.8, private@~0.1.5: +private@^0.1.8: version "0.1.8" resolved "https://registry.npmjs.org/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== @@ -19514,25 +20791,18 @@ progress@^2.0.0: resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prom-client@^12.0.0: - version "12.0.0" - resolved "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz#9689379b19bd3f6ab88a9866124db9da3d76c6ed" - integrity sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ== - dependencies: - tdigest "^0.1.1" - promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -promise-retry@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d" - integrity sha1-ZznpaOMFHaIM5kl/srUPaRHfPW0= +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== dependencies: - err-code "^1.0.0" - retry "^0.10.0" + err-code "^2.0.2" + retry "^0.12.0" promise.allsettled@^1.0.0: version "1.0.2" @@ -19588,7 +20858,7 @@ promzard@^0.3.0: dependencies: read "1" -prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -19597,11 +20867,6 @@ prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, object-assign "^4.1.1" react-is "^16.8.1" -propagate@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" - integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== - property-expr@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" @@ -19624,13 +20889,6 @@ protocols@^1.1.0, protocols@^1.4.0: resolved "https://registry.npmjs.org/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== -protoduck@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/protoduck/-/protoduck-5.0.1.tgz#03c3659ca18007b69a50fd82a7ebcc516261151f" - integrity sha512-WxoCeDCoCBY55BMvj4cAEjdVUFGRWed9ZxPlqTKYyw1nDDTQ4pqmnIMAGfJlg7Dx35uB/M+PHJPTmGOvaCaPTg== - dependencies: - genfun "^5.0.0" - proxy-addr@~2.0.5: version "2.0.6" resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" @@ -19656,7 +20914,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== @@ -19678,13 +20936,10 @@ public-encrypt@^4.0.0: randombytes "^2.0.1" safe-buffer "^5.1.2" -pump@^1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" - integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" +puka@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/puka/-/puka-1.0.1.tgz#a2df782b7eb4cf9564e4c93a5da422de0dfacc02" + integrity sha512-ssjRZxBd7BT3dte1RR3VoeT2cT/ODH8x+h0rUF1rMqB0srHYf48stSDWfiYakTp5UBZMxroZhB2+ExLDHm7W3g== pump@^2.0.0: version "2.0.1" @@ -19711,6 +20966,15 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" +pumpify@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" + integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== + dependencies: + duplexify "^4.1.1" + inherits "^2.0.3" + pump "^3.0.0" + punycode@1.3.2: version "1.3.2" resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" @@ -19733,7 +20997,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= @@ -19748,6 +21012,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" @@ -19790,11 +21059,6 @@ querystringify@^2.1.1: resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== -quick-lru@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" - integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= - quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -19822,11 +21086,6 @@ ramda@^0.21.0: resolved "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" integrity sha1-oAGr7bP/YQd9T/HVd9RN536NCjU= -ramda@^0.25.0: - version "0.25.0" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" - integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== - ramda@^0.26, ramda@~0.26.1: version "0.26.1" resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" @@ -19876,10 +21135,11 @@ raw-loader@^4.0.1: schema-utils "^2.6.5" rc-progress@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/rc-progress/-/rc-progress-3.0.0.tgz#cea324ce8fc31421cd815d94a4649a8a29f8f8db" - integrity sha512-dQv1KU3o6Vay604FMYMF4S0x4GNXAgXf1tbQ1QoxeIeQt4d5fUeB7Ri82YPu+G+aRvH/AtxYAlEcnxyVZ1/4Hw== + version "3.1.1" + resolved "https://registry.npmjs.org/rc-progress/-/rc-progress-3.1.1.tgz#f7ba5eb33cc438862513ab5a71739feaf6b3ef4f" + integrity sha512-1ns3pW7ll9bHfdXtlVLF+vngdvlxiCDtiqwXnZFEdurst11JTiPxVdeqnCNbhWx5hP4kCKkAPqG1N0FVfTSUGA== dependencies: + "@babel/runtime" "^7.10.1" classnames "^2.2.6" rc@^1.2.7, rc@^1.2.8: @@ -19975,31 +21235,21 @@ react-dev-utils@^10.0.0, react-dev-utils@^10.2.1: strip-ansi "6.0.0" text-table "0.2.0" -react-docgen-typescript-loader@^3.7.2: - version "3.7.2" - resolved "https://registry.npmjs.org/react-docgen-typescript-loader/-/react-docgen-typescript-loader-3.7.2.tgz#45cb2305652c0602767242a8700ad1ebd66bbbbd" - integrity sha512-fNzUayyUGzSyoOl7E89VaPKJk9dpvdSgyXg81cUkwy0u+NBvkzQG3FC5WBIlXda0k/iaxS+PWi+OC+tUiGxzPA== - dependencies: - "@webpack-contrib/schema-utils" "^1.0.0-beta.0" - loader-utils "^1.2.3" - react-docgen-typescript "^1.15.0" - -react-docgen-typescript-plugin@^0.5.2: - version "0.5.2" - resolved "https://registry.npmjs.org/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-0.5.2.tgz#2b294d75ef3145c36303da82be5d447cb67dc0dc" - integrity sha512-NQfWyWLmzUnedkiN2nPDb6Nkm68ik6fqbC3UvgjqYSeZsbKijXUA4bmV6aU7qICOXdop9PevPdjEgJuAN0nNVQ== +react-docgen-typescript-plugin@^0.6.2: + version "0.6.3" + resolved "https://registry.npmjs.org/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-0.6.3.tgz#664b22601df083597ecb1e60bd21beca60125fdf" + integrity sha512-av1S/fmWBNFGgNa4qtkidFjjOz23eEi6EdCtwSWo9WNhGzUMyMygbD/DosMWoeFlZpk9R3MXPkRE7PDH6j5GMQ== dependencies: debug "^4.1.1" endent "^2.0.1" micromatch "^4.0.2" - react-docgen-typescript "^1.20.1" - react-docgen-typescript-loader "^3.7.2" + react-docgen-typescript "^1.20.5" tslib "^2.0.0" -react-docgen-typescript@^1.15.0, react-docgen-typescript@^1.20.1: - version "1.20.4" - resolved "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-1.20.4.tgz#9a5655986077ccfc58c1a447f92c3d19f7a875bf" - integrity sha512-gE2SeseJd6+o981qr9VQJRbvFJ5LjLSKQiwhHsuLN4flt+lheKtG1jp2BPzrv2MKR5gmbLwpmNtK4wbLCPSZAw== +react-docgen-typescript@^1.20.5: + version "1.20.5" + resolved "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-1.20.5.tgz#fb8d78a707243498436c2952bd3f6f488a68d4f3" + integrity sha512-AbLGMtn76bn7SYBJSSaKJrZ0lgNRRR3qL60PucM5M4v/AXyC8221cKBXW5Pyt9TfDRfe+LDnPNlg7TibxX0ovA== react-docgen@^5.0.0: version "5.3.0" @@ -20015,7 +21265,7 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.12.0, react-dom@^16.13.1, react-dom@^16.8.3: +react-dom@^16.12.0, react-dom@^16.13.1: 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== @@ -20048,7 +21298,7 @@ react-fast-compare@^2.0.4: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-fast-compare@^3.1.1: +react-fast-compare@^3.0.1, react-fast-compare@^3.1.1: version "3.2.0" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== @@ -20130,7 +21380,7 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.12.0, 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== @@ -20161,9 +21411,9 @@ react-lifecycles-compat@^3.0.4: integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== react-markdown@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-5.0.2.tgz#d15a8beb37b4ec34fc23dd892e7755eb7040b8db" - integrity sha512-kmkB4JbV7LqkDAjvaKRKtodB3n3Id76/DalaDun1U8FuLB0SenPfvH+jAQ5Pcpo54cACRQc1LB1yXmuuuIVecw== + version "5.0.3" + resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-5.0.3.tgz#41040ea7a9324b564b328fb81dd6c04f2a5373ac" + integrity sha512-jDWOc1AvWn0WahpjW6NK64mtx6cwjM4iSsLHJPNBqoAgGOVoIdJMqaKX4++plhOtdd4JksdqzlDibgPx6B/M2w== dependencies: "@types/mdast" "^3.0.3" "@types/unist" "^2.0.3" @@ -20185,25 +21435,21 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-popper-tooltip@^2.11.0: - version "2.11.1" - resolved "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.11.1.tgz#3c4bdfd8bc10d1c2b9a162e859bab8958f5b2644" - integrity sha512-04A2f24GhyyMicKvg/koIOQ5BzlrRbKiAgP6L+Pdj1MVX3yJ1NeZ8+EidndQsbejFT55oW1b++wg2Z8KlAyhfQ== +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" + integrity sha512-EnERAnnKRptQBJyaee5GJScWNUKQPDD2ywvzZyUjst/wj5U64C8/CnSYLNEmP2hG0IJ3ZhtDxE8oDN+KOyavXQ== dependencies: - "@babel/runtime" "^7.9.2" - react-popper "^1.3.7" + "@babel/runtime" "^7.12.5" + "@popperjs/core" "^2.5.4" + react-popper "^2.2.4" -react-popper@^1.3.7: - version "1.3.7" - resolved "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324" - integrity sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww== +react-popper@^2.2.4: + version "2.2.4" + resolved "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz#d2ad3d2474ac9f1abf93df3099d408e5aa6a2e22" + integrity sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew== dependencies: - "@babel/runtime" "^7.1.2" - create-react-context "^0.3.0" - deep-equal "^1.1.1" - popper.js "^1.14.4" - prop-types "^15.6.1" - typed-styles "^0.0.7" + react-fast-compare "^3.0.1" warning "^4.0.2" react-redux@=4.4.10: @@ -20229,6 +21475,11 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" +react-refresh@^0.8.3: + version "0.8.3" + resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" + integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== + react-resize-detector@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" @@ -20239,7 +21490,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== @@ -20247,35 +21498,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" @@ -20322,10 +21544,10 @@ react-string-replace@^0.4.1: dependencies: lodash "^4.17.4" -react-syntax-highlighter@=13.5.0: - version "13.5.0" - resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.5.0.tgz#c0f3fd826a52b2a6ce5786d4ce60c3e0665b65c0" - integrity sha512-2nKo8spFxe9shcjbdUiqxkrf/IMDqKUZLx7JVIxEJ17P+fYFGL4CRsZZC66UPeQ2o/f29eKu31CrkKGCK1RHuA== +react-syntax-highlighter@^13.5.0, react-syntax-highlighter@^13.5.1: + version "13.5.3" + resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.5.3.tgz#9712850f883a3e19eb858cf93fad7bb357eea9c6" + integrity sha512-crPaF+QGPeHNIblxxCdf2Lg936NAHKhNhuMzRL3F9ct6aYXL3NcZtCL0Rms9+qVo6Y1EQLdXGypBNSbPL/r+qg== dependencies: "@babel/runtime" "^7.3.1" highlight.js "^10.1.1" @@ -20333,27 +21555,16 @@ react-syntax-highlighter@=13.5.0: prismjs "^1.21.0" refractor "^3.1.0" -react-syntax-highlighter@^12.2.1: - version "12.2.1" - resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz#14d78352da1c1c3f93c6698b70ec7c706b83493e" - integrity sha512-CTsp0ZWijwKRYFg9xhkWD4DSpQqE4vb2NKVMdPAkomnILSmsNBHE0n5GuI5zB+PU3ySVvXvdt9jo+ViD9XibCA== +react-syntax-highlighter@^15.4.3: + version "15.4.3" + resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.3.tgz#fffe3286677ac470b963b364916d16374996f3a6" + integrity sha512-TnhGgZKXr5o8a63uYdRTzeb8ijJOgRGe0qjrE0eK/gajtdyqnSO6LqB3vW16hHB0cFierYSoy/AOJw8z1Dui8g== dependencies: "@babel/runtime" "^7.3.1" - highlight.js "~9.15.1" - lowlight "1.12.1" - prismjs "^1.8.4" - refractor "^2.4.1" - -react-syntax-highlighter@^13.5.1: - version "13.5.1" - resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.5.1.tgz#f21737cf6d582474a0f18b06b52613f4349c0e64" - integrity sha512-VVYTnFXF55WMRGdr3QNEzAzcypFZqH45kS7rqh90+AFeNGtui8/gV5AIOIJjwTsuP2UxcO9qvEq94Jq9BYFUhw== - dependencies: - "@babel/runtime" "^7.3.1" - highlight.js "^10.1.1" - lowlight "^1.14.0" - prismjs "^1.21.0" - refractor "^3.1.0" + highlight.js "^10.4.1" + lowlight "^1.17.0" + prismjs "^1.22.0" + refractor "^3.2.0" react-test-renderer@^16.13.1: version "16.13.1" @@ -20365,6 +21576,13 @@ react-test-renderer@^16.13.1: react-is "^16.8.6" scheduler "^0.19.1" +react-text-truncate@^0.16.0: + version "0.16.0" + resolved "https://registry.npmjs.org/react-text-truncate/-/react-text-truncate-0.16.0.tgz#03bbb942437dfba5cc0faf614f1339f888926f80" + integrity sha512-hMFXhUHgIBCCDaOfOsZAFeO4DGfG/paLyaS/F+X11CXseuScpxmMBUW6Luwjk9/FlGrVJWNGy1FSfK6b4yyiIg== + dependencies: + prop-types "^15.5.7" + react-textarea-autosize@^8.1.1: version "8.2.0" resolved "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.2.0.tgz#fae38653f5ec172a855fd5fffb39e466d56aebdb" @@ -20416,10 +21634,10 @@ react-use@^12.2.0: ts-easing "^0.2.0" tslib "^1.10.0" -react-use@^15.3.3: - version "15.3.4" - resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.4.tgz#f853d310bd71f75b38900a8caa3db93f6dc6e872" - integrity sha512-cHq1dELW6122oi1+xX7lwNyE/ugZs5L902BuO8eFJCfn2api1KeuPVG1M/GJouVARoUf54S2dYFMKo5nQXdTag== +react-use@^15.3.3, react-use@^15.3.6: + version "15.3.8" + resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.8.tgz#ca839ac7fb3d696e5ccbeabbc8dadc2698969d30" + integrity sha512-GeGcrmGuUvZrY5wER3Lnph9DSYhZt5nEjped4eKDq8BRGr2CnLf9bDQWG9RFc7oCPphnscUUdOovzq0E5F2c6Q== dependencies: "@types/js-cookie" "2.2.6" "@xobotyi/scrollbar-width" "1.9.5" @@ -20453,7 +21671,7 @@ react-wait@^0.3.0: resolved "https://registry.npmjs.org/react-wait/-/react-wait-0.3.0.tgz#0cdd4d919012451a5bc3ab0a16d00c6fd9a8c10b" integrity sha512-kB5x/kMKWcn0uVr9gBdNz21/oGbQwEQnF3P9p6E9yLfJ9DRcKS0fagbgYMFI0YFOoyKDj+2q6Rwax0kTYJF37g== -react@^16.0.0, react@^16.12.0, react@^16.13.1, react@^16.8.3, react@^16.9.17: +react@^16.0.0, react@^16.12.0, react@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== @@ -20469,14 +21687,20 @@ reactcss@^1.2.0: dependencies: lodash "^4.0.1" -read-cmd-shim@^1.0.1: - version "1.0.5" - resolved "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16" - integrity sha512-v5yCqQ/7okKoZZkBQUAfTsQ3sVJtXdNfbPnI5cceppoxEVLYA3k+VtV2omkeo8MS94JCy4fSiUwlRBAwCVRPUA== - dependencies: - graceful-fs "^4.1.2" +read-cmd-shim@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-2.0.0.tgz#4a50a71d6f0965364938e9038476f7eede3928d9" + integrity sha512-HJpV9bQpkl6KwjxlJcBoqu9Ba0PQg8TqSNIOrulGt54a0uup0HtevreFHzYzkm0lpnleRdNBzXznKrgxglEHQw== -"read-package-json@1 || 2", read-package-json@^2.0.0, read-package-json@^2.0.13: +read-package-json-fast@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-2.0.1.tgz#c767f6c634873ffb6bb73788191b65559734f555" + integrity sha512-bp6z0tdgLy9KzdfENDIw/53HWAolOVoQTRWXv7PUiqAo3YvvoUVeLr7RWPWq+mu7KUOu9kiT4DvxhUgNUBsvug== + dependencies: + json-parse-even-better-errors "^2.3.0" + npm-normalize-package-bin "^1.0.1" + +read-package-json@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/read-package-json/-/read-package-json-2.1.1.tgz#16aa66c59e7d4dad6288f179dd9295fd59bb98f1" integrity sha512-dAiqGtVc/q5doFz6096CcnXhpYk0ZN8dEKVkGLU0CsASt8SrgF6SF7OTKAYubfvFhWaqofl+Y8HK19GR8jwW+A== @@ -20488,7 +21712,17 @@ read-cmd-shim@^1.0.1: optionalDependencies: graceful-fs "^4.1.2" -read-package-tree@^5.1.6: +read-package-json@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/read-package-json/-/read-package-json-3.0.0.tgz#2219328e77c9be34f035a4ce58d1fb8e2979adf9" + integrity sha512-4TnJZ5fnDs+/3deg1AuMExL4R1SFNRLQeOhV9c8oDKm3eoG6u8xU0r0mNNRJHi3K6B+jXmT7JOhwhAklWw9SSQ== + dependencies: + glob "^7.1.1" + json-parse-even-better-errors "^2.3.0" + normalize-package-data "^3.0.0" + npm-normalize-package-bin "^1.0.0" + +read-package-tree@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/read-package-tree/-/read-package-tree-5.3.1.tgz#a32cb64c7f31eb8a6f31ef06f9cedf74068fe636" integrity sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw== @@ -20593,7 +21827,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -20606,7 +21840,7 @@ read@1, read@~1.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -"readable-stream@2 || 3", readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: +readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -20648,16 +21882,6 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" -recast@^0.14.7: - version "0.14.7" - resolved "https://registry.npmjs.org/recast/-/recast-0.14.7.tgz#4f1497c2b5826d42a66e8e3c9d80c512983ff61d" - integrity sha512-/nwm9pkrcWagN40JeJhkPaRxiHXBRkXyRh/hgU088Z/v+qCy+zIHHY6bC6o7NaKAxPqtE6nD8zBH1LfU0/Wx6A== - dependencies: - ast-types "0.11.3" - esprima "~4.0.0" - private "~0.1.5" - source-map "~0.6.1" - recharts-scale@^0.4.2: version "0.4.3" resolved "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz#040b4f638ed687a530357292ecac880578384b59" @@ -20704,14 +21928,6 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" -redent@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" - integrity sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo= - dependencies: - indent-string "^3.0.0" - strip-indent "^2.0.0" - redent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -20766,15 +21982,6 @@ reflect-metadata@^0.1.13: resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== -refractor@^2.4.1: - version "2.10.1" - resolved "https://registry.npmjs.org/refractor/-/refractor-2.10.1.tgz#166c32f114ed16fd96190ad21d5193d3afc7d34e" - integrity sha512-Xh9o7hQiQlDbxo5/XkOX6H+x/q8rmlmZKr97Ie1Q8ZM32IRRd3B/UxuA/yXDW79DBSXGWxm2yRTbcTVmAciJRw== - dependencies: - hastscript "^5.0.0" - parse-entities "^1.1.2" - prismjs "~1.17.0" - refractor@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/refractor/-/refractor-3.1.0.tgz#b05a43c8a1b4fccb30001ffcbd5cd781f7f06f78" @@ -20784,6 +21991,15 @@ refractor@^3.1.0: parse-entities "^2.0.0" prismjs "~1.21.0" +refractor@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/refractor/-/refractor-3.2.0.tgz#bc46f7cfbb6adbf45cd304e8e299b7fa854804e0" + integrity sha512-hSo+EyMIZTLBvNNgIU5lW4yjCzNYMZ4dcEhBq/3nReGfqzd2JfVhdlPDfU9rEsgcAyWx+OimIIUoL4ZU7NtYHQ== + dependencies: + hastscript "^6.0.0" + parse-entities "^2.0.0" + prismjs "~1.22.0" + regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -20801,10 +22017,10 @@ 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.3, regenerator-runtime@^0.13.4, 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== regenerator-transform@^0.14.2: version "0.14.4" @@ -20847,6 +22063,18 @@ regexpu-core@^4.6.0, regexpu-core@^4.7.0: 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.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== + 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" + registry-auth-token@^4.0.0: version "4.1.1" resolved "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479" @@ -21052,6 +22280,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" @@ -21119,11 +22352,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" @@ -21187,16 +22415,18 @@ ret@~0.1.10: resolved "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry-request@^4.1.1: + version "4.1.3" + resolved "https://registry.npmjs.org/retry-request/-/retry-request-4.1.3.tgz#d5f74daf261372cff58d08b0a1979b4d7cab0fde" + integrity sha512-QnRZUpuPNgX0+D1xVxul6DbJ9slvo4Rm6iV/dn63e048MvGbUZiKySVt6Tenp04JqmchxjiLltGerOJys7kJYQ== + dependencies: + debug "^4.1.1" + retry@0.12.0, retry@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= -retry@^0.10.0: - version "0.10.1" - resolved "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" - integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= - reusify@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -21229,7 +22459,7 @@ rifm@^0.7.0: dependencies: "@babel/runtime" "^7.3.1" -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: +rimraf@2, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -21250,6 +22480,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" @@ -21266,18 +22503,18 @@ rollup-plugin-dts@1.4.13: "@babel/code-frame" "^7.10.4" rollup-plugin-esbuild@2.6.x: - version "2.6.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.0.tgz#80336399b113a179ccb2af5bdf7c03f061f37146" - integrity sha512-wtyDAX8kNABrBfwkrrG2xLRKRGSWUyMBURS067IRvRMpgJlLRo14WcFl95uGc4iYWfdAx436/z1LMzYqdlY4ig== + version "2.6.1" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.1.tgz#5785532940d49adf1bff5b38e9bd9089262d4e7a" + integrity sha512-hskMEQQ4Vxlyoeg1OWlFTwWHIhpNaw6q+diOT7p9pdkk34m9Mbk3aymS/JbTqLXy/AbJi22iuXrucknKpeczfg== dependencies: "@rollup/pluginutils" "^4.1.0" joycon "^2.2.5" strip-json-comments "^3.1.1" rollup-plugin-peer-deps-external@^2.2.2: - version "2.2.3" - resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.3.tgz#059a8aec1eefb48a475e9fcedc3b9e3deb521213" - integrity sha512-W6IePXTExGXVDAlfZbNUUrx3GxUOZP248u5n4a4ID1XZMrbQ+uGeNiEfapvdzwx0qZi5DNH/hDLiPUP+pzFIxg== + version "2.2.4" + resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz#8a420bbfd6dccc30aeb68c9bf57011f2f109570d" + integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== rollup-plugin-postcss@^3.1.1: version "3.1.8" @@ -21299,10 +22536,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" @@ -21380,7 +22617,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== @@ -21390,6 +22627,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" @@ -21397,7 +22639,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -21417,17 +22659,23 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -sanitize-html@^1.27.0: - version "1.27.0" - resolved "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.0.tgz#42104a2d59f1a48b616b5165ad5349824861e580" - integrity sha512-U1btucGeYVpg0GoK43jPpe/bDCV4cBOGuxzv5NBd0bOjyZdMKY0n98S/vNlO1wVwre0VCj8H3hbzE7gD2+RjKA== +sanitize-html@^2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.3.2.tgz#a1954aea877a096c408aca7b0c260bef6e4fc402" + integrity sha512-p7neuskvC8pSurUjdVmbWPXmc9A4+QpOXIL+4gwFC+av5h+lYCXFT8uEneqsFQg/wEA1IH+cKQA60AaQI6p3cg== dependencies: - chalk "^2.4.1" - htmlparser2 "^4.1.0" - lodash "^4.17.15" - postcss "^7.0.27" - srcset "^2.0.1" - xtend "^4.0.1" + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^6.0.0" + is-plain-object "^5.0.0" + klona "^2.0.3" + parse-srcset "^1.0.2" + postcss "^8.0.2" + +sax@1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/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" @@ -21467,6 +22715,15 @@ schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0, schema-utils@^2.7 ajv "^6.12.4" ajv-keywords "^3.5.2" +schema-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" + integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + dependencies: + "@types/json-schema" "^7.0.6" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + screenfull@^5.0.0: version "5.0.2" resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" @@ -21506,12 +22763,12 @@ 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: +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -21526,10 +22783,12 @@ semver@7.0.0: resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.x, semver@^7.2.1, semver@^7.3.2: - version "7.3.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" - integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: version "6.3.0" @@ -21651,7 +22910,7 @@ setprototypeof@1.2.0: resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: +sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8, sha.js@^2.4.9: version "2.4.11" resolved "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== @@ -21721,7 +22980,7 @@ shelljs@^0.8.2, shelljs@^0.8.3, shelljs@^0.8.4: shellwords@^0.1.1: version "0.1.1" - resolved "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== shortid@^2.2.14: @@ -21747,7 +23006,7 @@ side-channel@^1.0.2: es-abstract "^1.17.0-next.1" object-inspect "^1.7.0" -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== @@ -21762,7 +23021,7 @@ simple-concat@^1.0.0: resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -simple-get@^3.0.3: +simple-get@^3.0.2, simple-get@^3.0.3: version "3.1.0" resolved "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== @@ -21851,6 +23110,11 @@ smartwrap@^1.2.3: wcwidth "^1.0.1" yargs "^15.1.0" +snakeize@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d" + integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0= + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -21902,20 +23166,21 @@ sockjs@0.3.20: uuid "^3.4.0" websocket-driver "0.6.5" -socks-proxy-agent@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386" - integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg== +socks-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.0.tgz#7c0f364e7b1cf4a7a437e71253bed72e9004be60" + integrity sha512-lEpa1zsWCChxiynk+lCycKuC502RxDWLKJZoIhnxrWNjLSDGYRFflHA1/228VkRcnv9TIb8w98derGbpKxJRgA== dependencies: - agent-base "~4.2.1" - socks "~2.3.2" + agent-base "6" + debug "4" + socks "^2.3.3" -socks@~2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3" - integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA== +socks@^2.3.3: + version "2.5.1" + resolved "https://registry.npmjs.org/socks/-/socks-2.5.1.tgz#7720640b6b5ec9a07d556419203baa3f0596df5f" + integrity sha512-oZCsJJxapULAYJaEYBSzMcz8m3jqgGrHaGhkmU/o/PQfFWYWxkAaA0UMGImb6s6tEXfKi959X6VJjMMQ3P6TTQ== dependencies: - ip "1.1.5" + ip "^1.1.5" smart-buffer "^4.1.0" sort-keys@^1.0.0: @@ -21932,6 +23197,13 @@ sort-keys@^2.0.0: dependencies: is-plain-obj "^1.0.0" +sort-keys@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/sort-keys/-/sort-keys-4.2.0.tgz#6b7638cee42c506fff8c1cecde7376d21315be18" + integrity sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg== + dependencies: + is-plain-obj "^2.0.0" + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -22078,12 +23350,12 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" -split2@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" - integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw== +split2@^3.0.0: + version "3.2.2" + resolved "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" + integrity sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg== dependencies: - through2 "^2.0.2" + readable-stream "^3.0.0" split@0.3: version "0.3.3" @@ -22114,11 +23386,6 @@ sqlite3@^5.0.0: optionalDependencies: node-gyp "3.x" -srcset@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz#8f842d357487eb797f413d9c309de7a5149df5ac" - integrity sha512-00kZI87TdRKwt+P8jj8UZxbfp7mK2ufxcIMWvhAOZNJTRROimpHeruWrGvCZneiuVDLqdyHefVp748ECTnyUBQ== - ssh2-streams@~0.4.10: version "0.4.10" resolved "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34" @@ -22150,7 +23417,7 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" -ssri@^6.0.0, ssri@^6.0.1: +ssri@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== @@ -22164,6 +23431,13 @@ ssri@^8.0.0: dependencies: minipass "^3.1.1" +ssri@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" + integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== + dependencies: + minipass "^3.1.1" + stable@^0.1.8: version "0.1.8" resolved "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" @@ -22181,6 +23455,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" @@ -22266,7 +23547,7 @@ store2@^2.7.1: resolved "https://registry.npmjs.org/store2/-/store2-2.10.0.tgz#46b82bb91878daf1b0d56dec2f1d41e54d5103cf" integrity sha512-tWEpK0snS2RPUq1i3R6OahfJNjWCQYNxq0+by1amCSuw0mXtymJpzmZIeYpA1UAa+7B0grCpNYIbDcd7AgTbFg== -storybook-dark-mode@^1.0.2: +storybook-dark-mode@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.3.tgz#8d58a874b6107ff1a7f29ebb0e6726b8036eee08" integrity sha512-mjHLrv/dwtqKmbOoQ2CMtGKDttWSnUybutujsIPxLcEC77EujjWiRBFv46LtXAZEyZLm8sGFUz0s6HJJfJ3tSw== @@ -22302,6 +23583,13 @@ stream-each@^1.1.0: end-of-stream "^1.1.0" stream-shift "^1.0.0" +stream-events@^1.0.1, stream-events@^1.0.4, stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + stream-http@^2.7.2: version "2.8.3" resolved "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" @@ -22379,7 +23667,7 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -22550,11 +23838,6 @@ strip-indent@^1.0.1: dependencies: get-stdin "^4.0.1" -strip-indent@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" - integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= - strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -22572,7 +23855,7 @@ strip-json-comments@~2.0.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strong-log-transformer@^2.0.0: +strong-log-transformer@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10" integrity sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA== @@ -22581,6 +23864,11 @@ strong-log-transformer@^2.0.0: minimist "^1.2.0" through "^2.3.4" +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha1-6NK6H6nJBXAwPAMLaQD31fiavls= + style-inject@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" @@ -22594,25 +23882,6 @@ style-loader@^1.2.1: loader-utils "^2.0.0" schema-utils "^2.6.6" -styled-system@^5.1.5: - version "5.1.5" - resolved "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz#e362d73e1dbb5641a2fd749a6eba1263dc85075e" - integrity sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A== - dependencies: - "@styled-system/background" "^5.1.2" - "@styled-system/border" "^5.1.5" - "@styled-system/color" "^5.1.2" - "@styled-system/core" "^5.1.2" - "@styled-system/flexbox" "^5.1.2" - "@styled-system/grid" "^5.1.2" - "@styled-system/layout" "^5.1.2" - "@styled-system/position" "^5.1.2" - "@styled-system/shadow" "^5.1.2" - "@styled-system/space" "^5.1.2" - "@styled-system/typography" "^5.1.2" - "@styled-system/variant" "^5.1.5" - object-assign "^4.1.1" - stylehacks@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" @@ -22627,7 +23896,7 @@ stylis@3.5.0: resolved "https://registry.npmjs.org/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1" integrity sha512-pP7yXN6dwMzAR29Q0mBrabPCe0/mNO1MSr93bhay+hcZondvMMTpeGyd8nbhYJdyperNT2DRxONQuUGcJr5iPw== -subscriptions-transport-ws@0.9.18: +subscriptions-transport-ws@0.9.18, subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16, subscriptions-transport-ws@^0.9.18: version "0.9.18" resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz#bcf02320c911fbadb054f7f928e51c6041a37b97" integrity sha512-tztzcBTNoEbuErsVQpTN2xUNN/efAZXyCyL5m3x4t6SKrEiTL2N8SaKWBFWM4u56pL79ULif3zjyeq+oV+nOaA== @@ -22638,21 +23907,10 @@ subscriptions-transport-ws@0.9.18: symbol-observable "^1.0.4" ws "^5.2.0" -subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16: - version "0.9.17" - resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.17.tgz#e30e40f0caae0d2781903c01a8cb51b6e2682098" - integrity sha512-hNHi2N80PBz4T0V0QhnnsMGvG3XDFDS9mS6BhZ3R12T6EBywC8d/uJscsga0cVO4DKtXCkCRrWm2sOYrbOdhEA== - dependencies: - backo2 "^1.0.2" - eventemitter3 "^3.1.0" - iterall "^1.2.1" - symbol-observable "^1.0.4" - 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" @@ -22771,9 +24029,9 @@ swagger-client@^3.12.1: url "~0.11.0" swagger-ui-react@^3.37.2: - version "3.37.2" - resolved "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-3.37.2.tgz#17d78116e9970ebbe2b92cfaf87896faeb62624a" - integrity sha512-QTR/aqcQ/Yvr9StryUhmDcfO9rMFUe0RgsOlZ26j3uqkRAvY+70uaz+FAn5o//jWQsSwh73HOZMzb0OlySYFCQ== + version "3.38.0" + resolved "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-3.38.0.tgz#eebe7f03ba18e2b12cf921319da868729aadd3df" + integrity sha512-jkTRzKEWhilsHexZw1t9JvrDAInaGK9o1rF9EOXYqlb6wXeFVn5kK11eP6RhTRbYW6gM+unY2y10KV3dNbV5Mw== dependencies: "@babel/runtime-corejs3" "^7.11.2" "@braintree/sanitize-url" "^5.0.0" @@ -22783,7 +24041,7 @@ swagger-ui-react@^3.37.2: classnames "^2.2.6" css.escape "1.5.1" deep-extend "0.6.0" - dompurify "=2.2.2" + dompurify "^2.2.3" ieee754 "^1.1.13" immutable "^3.x.x" js-file-download "^0.4.1" @@ -22799,7 +24057,7 @@ swagger-ui-react@^3.37.2: react-inspector "^2.3.0" react-motion "^0.5.2" react-redux "=4.4.10" - react-syntax-highlighter "=13.5.0" + react-syntax-highlighter "^15.4.3" redux "=3.7.2" redux-immutable "3.1.0" remarkable "^2.0.1" @@ -22856,15 +24114,15 @@ 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@^1.16.3: - version "1.16.3" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509" - integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw== +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.0.1" - mkdirp "^0.5.1" - pump "^1.0.0" - tar-stream "^1.1.2" + 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" @@ -22876,31 +24134,7 @@ tar-fs@~2.0.1: pump "^3.0.0" tar-stream "^2.0.0" -tar-stream@^1.1.2: - version "1.6.2" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== - dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" - -tar-stream@^2.0.0: - version "2.1.2" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.2.tgz#6d5ef1a7e5783a95ff70b69b97455a5968dc1325" - integrity sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q== - dependencies: - bl "^4.0.1" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar-stream@^2.1.4: +tar-stream@^2.0.0, tar-stream@^2.1.4: version "2.1.4" resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz#c4fb1a11eb0da29b893a5b25476397ba2d053bfa" integrity sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw== @@ -22920,7 +24154,7 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: +tar@^4, tar@^4.4.12: version "4.4.13" resolved "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== @@ -22945,17 +24179,33 @@ tar@^6.0.1, tar@^6.0.2, tar@^6.0.5: mkdirp "^1.0.3" yallist "^4.0.0" +tar@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" + integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + tarn@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec" integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw== -tdigest@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" - integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= +teeny-request@^7.0.0: + version "7.0.1" + resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz#bdd41fdffea5f8fbc0d29392cb47bec4f66b2b4c" + integrity sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw== dependencies: - bintrees "1.0.1" + http-proxy-agent "^4.0.0" + https-proxy-agent "^5.0.0" + node-fetch "^2.6.1" + stream-events "^1.0.5" + uuid "^8.0.0" telejson@^5.0.2: version "5.0.2" @@ -22976,17 +24226,16 @@ temp-dir@^1.0.0: resolved "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" integrity sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0= -temp-write@^3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/temp-write/-/temp-write-3.4.0.tgz#8cff630fb7e9da05f047c74ce4ce4d685457d492" - integrity sha1-jP9jD7fp2gXwR8dM5M5NaFRX1JI= +temp-write@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/temp-write/-/temp-write-4.0.0.tgz#cd2e0825fc826ae72d201dc26eef3bf7e6fc9320" + integrity sha512-HIeWmj77uOOHb0QX7siN3OtwV3CTntquin6TNVg6SHOqCP3hYKmox90eeFOGaY1MqJ9WYDDjkyZrW6qS5AWpbw== dependencies: - graceful-fs "^4.1.2" - is-stream "^1.1.0" - make-dir "^1.0.0" - pify "^3.0.0" + graceful-fs "^4.1.15" + is-stream "^2.0.0" + make-dir "^3.0.0" temp-dir "^1.0.0" - uuid "^3.0.1" + uuid "^3.3.2" term-size@^1.2.0: version "1.2.0" @@ -23076,18 +24325,6 @@ text-table@0.2.0, text-table@^0.2.0: resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= -theme-ui@^0.3.1: - version "0.3.1" - resolved "https://registry.npmjs.org/theme-ui/-/theme-ui-0.3.1.tgz#b00ee2c03eb3d820536af8b121c64d13b3777cf0" - integrity sha512-My/TSALqp7Dst5Ez7nJA+94Q8zJhc26Z0qGo8kEWyoqHHJ5TU8xdhjLPBltTdQck3T32cSq5USIeSKU3JtxYUQ== - dependencies: - "@theme-ui/color-modes" "^0.3.1" - "@theme-ui/components" "^0.3.1" - "@theme-ui/core" "^0.3.1" - "@theme-ui/css" "^0.3.1" - "@theme-ui/mdx" "^0.3.0" - "@theme-ui/theme-provider" "^0.3.1" - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -23117,7 +24354,7 @@ throttleit@^1.0.0: resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= -through2@^2.0.0, through2@^2.0.2: +through2@^2.0.0: version "2.0.5" resolved "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== @@ -23125,12 +24362,12 @@ through2@^2.0.0, through2@^2.0.2: readable-stream "~2.3.6" xtend "~4.0.1" -through2@^3.0.0: - version "3.0.1" - resolved "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a" - integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww== +through2@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz#a7ce3ac2a7a8b0b966c80e7c49f0484c3b239764" + integrity sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw== dependencies: - readable-stream "2 || 3" + readable-stream "3" through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.1: version "2.3.8" @@ -23177,7 +24414,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== @@ -23187,7 +24424,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== @@ -23235,11 +24472,6 @@ to-arraybuffer@^1.0.0: resolved "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== - to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -23257,11 +24489,6 @@ to-readable-stream@^1.0.0: resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== -to-readable-stream@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz#82880316121bea662cdc226adb30addb50cb06e8" - integrity sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -23331,6 +24558,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" @@ -23365,10 +24601,10 @@ trim-newlines@^1.0.0: resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" integrity sha1-WIeWa7WCpFA6QetST301ARgVphM= -trim-newlines@^2.0.0: - version "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" @@ -23390,10 +24626,10 @@ tryer@^1.0.1: resolved "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== -ts-dedent@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/ts-dedent/-/ts-dedent-1.1.1.tgz#68fad040d7dbd53a90f545b450702340e17d18f3" - integrity sha512-UGTRZu1evMw4uTPyYF66/KFd22XiU+jMaIuHrkIHQ2GivAXVlLV0v/vHrpOuTRf9BmpNHi/SO7Vd0rLu0y57jg== +ts-dedent@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.0.0.tgz#47c5eb23d9096f3237cc413bc82d387d36dbe690" + integrity sha512-DfxKjSFQfw9+uf7N9Cy8Ebx9fv5fquK4hZ6SD3Rzr+1jKP6AVA6H8+B5457ZpUs0JKsGpGqIevbpZ9DMQJDp1A== ts-easing@^0.2.0: version "0.2.0" @@ -23462,6 +24698,18 @@ ts-node@^8.10.2, ts-node@^8.6.2: source-map-support "^0.5.17" yn "3.1.1" +ts-node@^9.1.1: + version "9.1.1" + resolved "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" + integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== + dependencies: + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" @@ -23487,15 +24735,25 @@ 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.0, tslib@^2.0.1, tslib@^2.0.3, tslib@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + +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== tsutils@^3.17.1: version "3.17.1" @@ -23509,7 +24767,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== @@ -23528,7 +24786,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== @@ -23557,20 +24815,25 @@ type-detect@4.0.8: resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== -type-fest@^0.10.0: - version "0.10.0" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz#7f06b2b9fbfc581068d1341ffabd0349ceafc642" - integrity sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw== - type-fest@^0.11.0: version "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.3.0: - version "0.3.1" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" - integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== +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.18.0: + version "0.18.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" + integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== + +type-fest@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.4.1.tgz#8bdf77743385d8a4f13ba95f610f5ccd68c728f8" + integrity sha512-IwzA/LSfD2vC1/YDYMv/zHP4rDF1usCwllsDpbolT3D4fUepIO7f9K70jjmUewU/LmGUKJcwcVtDCpnKk4BPMw== type-fest@^0.6.0: version "0.6.0" @@ -23600,20 +24863,15 @@ 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" underscore "1.8.3" -typed-styles@^0.0.7: - version "0.0.7" - resolved "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" - integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== - typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -23626,21 +24884,22 @@ 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.0" - resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.45.0.tgz#2f244e99518e589a442ee5f9c1d2c85a1ec7dc84" - integrity sha512-4MeX0HIODRd+K1/sIbkPryGXG43PVQ9cvIC8gDYml6yBgcsWLjvPMpTNr9VV+bQwpDLncB8Ie4qSenuKUwNZpg== +typescript-json-schema@^0.49.0: + version "0.49.0" + resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.49.0.tgz#442f6347ca85fb0d9811f217fb0d6537b68734b3" + integrity sha512-PumZkTmEE3T8TVyoJU6ZCp3K6VCmCb3Ei6fUaRIuDsIzYtmdJc6jV1D0RyBe5sd5mJ1iB6Zckm4KAKbqXs9oDw== dependencies: "@types/json-schema" "^7.0.6" glob "^7.1.6" json-stable-stringify "^1.0.1" - typescript "^4.1.2" - yargs "^16.1.1" + ts-node "^9.1.1" + typescript "^4.1.3" + yargs "^16.2.0" -typescript@^4.0.3, typescript@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" - integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== +typescript@^4.0.3, typescript@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@^0.7.18: version "0.7.21" @@ -23687,7 +24946,7 @@ unc-path-regex@^0.1.2: resolved "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= -undefsafe@^2.0.2: +undefsafe@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz#6b166e7094ad46313b2202da7ecc2cd7cc6e7aae" integrity sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A== @@ -23819,12 +25078,13 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" -universal-user-agent@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557" - integrity sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg== +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: - os-name "^3.1.0" + "@types/jsonwebtoken" "^8.3.3" + jsonwebtoken "^8.5.1" universal-user-agent@^5.0.0: version "5.0.0" @@ -23838,7 +25098,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== @@ -23848,6 +25108,11 @@ universalify@^1.0.0: 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.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unixify@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" @@ -23894,15 +25159,20 @@ unzipper@^0.10.11: readable-stream "~2.3.6" setimmediate "~1.0.4" -upath@^1.1.1, upath@^1.2.0: +upath@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== -update-notifier@^4.0.0: - version "4.1.0" - resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3" - integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew== +upath@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" + integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== + +update-notifier@^4.1.0: + version "4.1.3" + resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" + integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== dependencies: boxen "^4.2.0" chalk "^3.0.0" @@ -23938,13 +25208,13 @@ urix@^0.1.0: integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= url-loader@^4.0.0, url-loader@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/url-loader/-/url-loader-4.1.0.tgz#c7d6b0d6b0fccd51ab3ffc58a78d32b8d89a7be2" - integrity sha512-IzgAAIC8wRrg6NYkFIJY09vtktQcsvU8V6HhtQj9PTefbYImzLB1hufqo4m+RyM5N3mLx5BqJKccgxJS+W3kqw== + version "4.1.1" + resolved "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz#28505e905cae158cf07c92ca622d7f237e70a4e2" + integrity sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA== dependencies: loader-utils "^2.0.0" - mime-types "^2.1.26" - schema-utils "^2.6.5" + mime-types "^2.1.27" + schema-utils "^3.0.0" url-parse-lax@^3.0.0: version "3.0.0" @@ -23961,10 +25231,13 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" -url-value-parser@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed" - integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw== +url@0.10.3: + version "0.10.3" + resolved "https://registry.npmjs.org/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" @@ -23981,6 +25254,11 @@ use-composed-ref@^1.0.0: dependencies: ts-essentials "^2.0.3" +use-immer@^0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/use-immer/-/use-immer-0.4.2.tgz#7d7e7d3386cc834d45b2279f37d8974023550f4f" + integrity sha512-ONfZHEv/gzt/jyYxrJD3ZFUllKJED8F1mds9Fr9CYj54LmsiuGDg3vkx+R7WHS72p7DbSS1QbM7xNYrVWX+KcA== + use-isomorphic-layout-effect@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.0.0.tgz#f56b4ed633e1c21cd9fc76fe249002a1c28989fb" @@ -24064,7 +25342,12 @@ 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.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +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== @@ -24075,19 +25358,19 @@ uuid@^7.0.3: integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== uuid@^8.0.0, uuid@^8.2.0, uuid@^8.3.0: - version "8.3.0" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" - integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== -v8-to-istanbul@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-6.0.1.tgz#7ef0e32faa10f841fe4c1b0f8de96ed067c0be1e" - integrity sha512-PzM1WlqquhBvsV+Gco6WSFeg1AGdD53ccMRkFeyHRE/KRZaVacPOmQYP3EeVgDBtKD2BJ8kgynBQ5OtKiHCH+w== +v8-to-istanbul@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz#b4fe00e35649ef7785a9b7fcebcea05f37c332fc" + integrity sha512-fLL2rFuQpMtm9r8hrAV2apXX/WqHJ6+IC4/eQVdMDGBUgH/YMV4Gv3duk3kjmyg6uiQWBAA9nJwue4iJUOkHeA== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" @@ -24105,7 +25388,7 @@ valid-url@1.0.9, valid-url@^1.0.9: resolved "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA= -validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3: +validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== @@ -24150,11 +25433,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" @@ -24354,6 +25632,11 @@ webpack-dev-server@^3.11.0: ws "^6.2.1" yargs "^13.3.2" +webpack-filter-warnings-plugin@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz#dc61521cf4f9b4a336fbc89108a75ae1da951cdb" + integrity sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg== + webpack-hot-middleware@^2.25.0: version "2.25.0" resolved "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.0.tgz#4528a0a63ec37f8f8ef565cf9e534d57d09fe706" @@ -24364,16 +25647,6 @@ webpack-hot-middleware@^2.25.0: querystring "^0.2.0" strip-ansi "^3.0.0" -webpack-log@^1.1.2: - version "1.2.0" - resolved "https://registry.npmjs.org/webpack-log/-/webpack-log-1.2.0.tgz#a4b34cda6b22b518dbb0ab32e567962d5c72a43d" - integrity sha512-U9AnICnu50HXtiqiDxuli5gLB5PGBo7VvcHx36jRZHwK4vzOYLbImqT4lwWwoMHdQWwEKw736fCHEekokTEKHA== - dependencies: - chalk "^2.1.0" - log-symbols "^2.1.0" - loglevelnext "^1.0.1" - uuid "^3.1.0" - webpack-log@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" @@ -24402,10 +25675,10 @@ webpack-virtual-modules@^0.2.2: dependencies: debug "^3.0.0" -webpack@^4.41.6, webpack@^4.43.0: - version "4.44.1" - resolved "https://registry.npmjs.org/webpack/-/webpack-4.44.1.tgz#17e69fff9f321b8f117d1fda714edfc0b939cc21" - integrity sha512-4UOGAohv/VGUNQJstzEywwNxqX417FnjZgZJpJQegddzPmTvph37eBIRbRTfdySXzVtJXLJfbMN3mMYhM6GdmQ== +webpack@^4.41.6, webpack@^4.44.2: + version "4.44.2" + resolved "https://registry.npmjs.org/webpack/-/webpack-4.44.2.tgz#6bfe2b0af055c8b2d1e90ed2cd9363f841266b72" + integrity sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q== dependencies: "@webassemblyjs/ast" "1.9.0" "@webassemblyjs/helper-module-context" "1.9.0" @@ -24513,6 +25786,15 @@ whatwg-url@^8.0.0: tr46 "^2.0.2" webidl-conversions "^5.0.0" +whatwg-url@^8.4.0: + version "8.4.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" + integrity sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^2.0.2" + webidl-conversions "^6.1.0" + which-module@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" @@ -24678,7 +25960,7 @@ wrappy@1: resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -write-file-atomic@^2.0.0, write-file-atomic@^2.3.0, write-file-atomic@^2.4.2: +write-file-atomic@^2.4.2: version "2.4.3" resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481" integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ== @@ -24687,7 +25969,7 @@ write-file-atomic@^2.0.0, write-file-atomic@^2.3.0, write-file-atomic@^2.4.2: imurmurhash "^0.1.4" signal-exit "^3.0.2" -write-file-atomic@^3.0.0: +write-file-atomic@^3.0.0, write-file-atomic@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== @@ -24697,18 +25979,6 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write-json-file@^2.2.0: - version "2.3.0" - resolved "https://registry.npmjs.org/write-json-file/-/write-json-file-2.3.0.tgz#2b64c8a33004d54b8698c76d585a77ceb61da32f" - integrity sha1-K2TIozAE1UuGmMdtWFp3zrYdoy8= - dependencies: - detect-indent "^5.0.0" - graceful-fs "^4.1.2" - make-dir "^1.0.0" - pify "^3.0.0" - sort-keys "^2.0.0" - write-file-atomic "^2.0.0" - write-json-file@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/write-json-file/-/write-json-file-3.2.0.tgz#65bbdc9ecd8a1458e15952770ccbadfcff5fe62a" @@ -24721,13 +25991,26 @@ write-json-file@^3.2.0: sort-keys "^2.0.0" write-file-atomic "^2.4.2" -write-pkg@^3.1.0: - version "3.2.0" - resolved "https://registry.npmjs.org/write-pkg/-/write-pkg-3.2.0.tgz#0e178fe97820d389a8928bc79535dbe68c2cff21" - integrity sha512-tX2ifZ0YqEFOF1wjRW2Pk93NLsj02+n1UP5RvO6rCs0K6R2g1padvf006cY74PQJKMGS2r42NK7FD0dG6Y6paw== +write-json-file@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/write-json-file/-/write-json-file-4.3.0.tgz#908493d6fd23225344af324016e4ca8f702dd12d" + integrity sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ== + dependencies: + detect-indent "^6.0.0" + graceful-fs "^4.1.15" + is-plain-obj "^2.0.0" + make-dir "^3.0.0" + sort-keys "^4.0.0" + write-file-atomic "^3.0.0" + +write-pkg@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/write-pkg/-/write-pkg-4.0.0.tgz#675cc04ef6c11faacbbc7771b24c0abbf2a20039" + integrity sha512-v2UQ+50TNf2rNHJ8NyWttfm/EJUBWMJcx6ZTYZr6Qp52uuegWw/lBkCtCbnYZEmPRNL61m+u67dAmGxo+HTULA== dependencies: sort-keys "^2.0.0" - write-json-file "^2.2.0" + type-fest "^0.4.1" + write-json-file "^3.2.0" write@1.0.3: version "1.0.3" @@ -24750,12 +26033,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== @@ -24777,15 +26055,15 @@ 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" -xml-encryption@^1.0.0: +xml-encryption@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.1.tgz#e6d18817c4309fd07ca7793cca93c3fd06745baa" integrity sha512-hn5w3l5p2+nGjlmM0CAhMChDzVGhW+M37jH35Z+GJIipXbn9PUlAIRZ6I5Wm7ynlqZjFrMAr83d/CIp9VZJMTA== @@ -24800,7 +26078,15 @@ 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.x: +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + +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== @@ -24813,6 +26099,11 @@ 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.npmjs.org/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" @@ -24878,7 +26169,7 @@ yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: yallist@^4.0.0: version "4.0.0" - resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml-ast-parser@0.0.43, yaml-ast-parser@^0.0.43: @@ -24886,23 +26177,21 @@ 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== +yargs-parser@20.2.4, yargs-parser@^20.2.2, yargs-parser@^20.2.3: + version "20.2.4" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + yargs-parser@20.x: version "20.2.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.3.tgz#92419ba867b858c868acf8bae9bf74af0dd0ce26" integrity sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww== -yargs-parser@^10.0.0: - version "10.1.0" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" - integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== - dependencies: - camelcase "^4.1.0" - yargs-parser@^13.1.2: version "13.1.2" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" @@ -24911,15 +26200,7 @@ yargs-parser@^13.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^15.0.1: - version "15.0.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3" - integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw== - dependencies: - 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== @@ -24927,11 +26208,6 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.2: - version "20.2.4" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - yargs-parser@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-3.2.0.tgz#5081355d19d9d0c8c5d81ada908cb4e6d186664f" @@ -24956,23 +26232,6 @@ yargs@^13.3.0, yargs@^13.3.2: y18n "^4.0.0" yargs-parser "^13.1.2" -yargs@^14.2.2: - version "14.2.3" - resolved "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414" - integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg== - dependencies: - cliui "^5.0.0" - decamelize "^1.2.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^15.0.1" - yargs@^15.1.0, yargs@^15.3.1, yargs@^15.4.1: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" @@ -24990,10 +26249,10 @@ yargs@^15.1.0, yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.3, yargs@^16.1.1: - version "16.1.1" - resolved "https://registry.npmjs.org/yargs/-/yargs-16.1.1.tgz#5a4a095bd1ca806b0a50d0c03611d38034d219a1" - integrity sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w== +yargs@^16.0.3, yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== dependencies: cliui "^7.0.2" escalade "^3.1.1" @@ -25049,6 +26308,11 @@ yn@^4.0.0: resolved "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz#611480051ea43b510da1dfdbe177ed159f00a979" integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + yup@^0.29.3: version "0.29.3" resolved "https://registry.npmjs.org/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" @@ -25080,13 +26344,13 @@ zenscroll@^4.0.2: resolved "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz#e8d5774d1c0738a47bcfa8729f3712e2deddeb25" integrity sha1-6NV3TRwHOKR7z6hynzcS4t7d6yU= -zip-stream@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.0.2.tgz#3a20f1bd7729c2b59fd4efa04df5eb7a5a217d2e" - integrity sha512-TGxB2g+1ur6MHkvM644DuZr8Uzyz0k0OYWtS3YlpfWBEmK4woaC2t3+pozEL3dBfIPmpgmClR5B2QRcMgGt22g== +zip-stream@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.0.4.tgz#3a8f100b73afaa7d1ae9338d910b321dec77ff3a" + integrity sha512-a65wQ3h5gcQ/nQGWV1mSZCEzCML6EK/vyVPcrPNynySP1j3VBbQKh3nhC8CbORb+jfl2vXvh56Ul5odP1bAHqw== dependencies: archiver-utils "^2.1.0" - compress-commons "^4.0.0" + compress-commons "^4.0.2" readable-stream "^3.6.0" zombie@^6.1.4: