diff --git a/.changeset/clean-rocks-ring.md b/.changeset/clean-rocks-ring.md new file mode 100644 index 0000000000..706f3640c2 --- /dev/null +++ b/.changeset/clean-rocks-ring.md @@ -0,0 +1,18 @@ +--- +'@backstage/cli': minor +--- + +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 :( diff --git a/.changeset/cost-insights-curvy-dingos-live.md b/.changeset/cost-insights-curvy-dingos-live.md new file mode 100644 index 0000000000..f15d3b0492 --- /dev/null +++ b/.changeset/cost-insights-curvy-dingos-live.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Allow expand functionality to top panel product chart tooltip. diff --git a/.changeset/cuddly-files-argue.md b/.changeset/cuddly-files-argue.md deleted file mode 100644 index d495a945f4..0000000000 --- a/.changeset/cuddly-files-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Replace `register-component` plugin with new `catalog-import` plugin diff --git a/.changeset/cyan-lizards-confess.md b/.changeset/cyan-lizards-confess.md deleted file mode 100644 index 17ff8f0cdf..0000000000 --- a/.changeset/cyan-lizards-confess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Fix HTTPS certificate generation and add new config switch, enabling it simply by setting `backend.https = true`. Also introduces caching of generated certificates in order to avoid having to add a browser override every time the backend is restarted. diff --git a/.changeset/five-games-grin.md b/.changeset/five-games-grin.md new file mode 100644 index 0000000000..492c92880c --- /dev/null +++ b/.changeset/five-games-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set. diff --git a/.changeset/happy-crabs-punch.md b/.changeset/happy-crabs-punch.md new file mode 100644 index 0000000000..4c68dbea1a --- /dev/null +++ b/.changeset/happy-crabs-punch.md @@ -0,0 +1,12 @@ +--- +'@backstage/config-loader': patch +--- + +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} +``` diff --git a/.changeset/healthy-cameras-suffer.md b/.changeset/healthy-cameras-suffer.md new file mode 100644 index 0000000000..13d74011dc --- /dev/null +++ b/.changeset/healthy-cameras-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Improve error reporting for plugin misconfiguration. diff --git a/.changeset/honest-jokes-rush.md b/.changeset/honest-jokes-rush.md new file mode 100644 index 0000000000..31ec6202da --- /dev/null +++ b/.changeset/honest-jokes-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Use .text instead of .json for ALB key response diff --git a/.changeset/kind-buses-change.md b/.changeset/kind-buses-change.md deleted file mode 100644 index 1d93e27dff..0000000000 --- a/.changeset/kind-buses-change.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/techdocs-common': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Moving the Git actions to isomorphic-git instead of the node binding version of nodegit diff --git a/.changeset/lucky-guests-mate.md b/.changeset/lucky-guests-mate.md new file mode 100644 index 0000000000..fd733aac75 --- /dev/null +++ b/.changeset/lucky-guests-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Removed support for the deprecated `$data` placeholder. diff --git a/.changeset/mighty-plums-wave.md b/.changeset/mighty-plums-wave.md deleted file mode 100644 index ac84b68e04..0000000000 --- a/.changeset/mighty-plums-wave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Tweaked development log formatter to include extra fields at the end of each log line diff --git a/.changeset/new-horses-protect.md b/.changeset/new-horses-protect.md deleted file mode 100644 index 706011fb2c..0000000000 --- a/.changeset/new-horses-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Add processor for ingesting AWS accounts from AWS Organizations diff --git a/.changeset/odd-eyes-beg.md b/.changeset/odd-eyes-beg.md deleted file mode 100644 index 25a7699516..0000000000 --- a/.changeset/odd-eyes-beg.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/integration': patch ---- - -Provide support for on-prem azure devops diff --git a/.changeset/techdocs-dirty-rivers-hope.md b/.changeset/techdocs-dirty-rivers-hope.md new file mode 100644 index 0000000000..3f49afa529 --- /dev/null +++ b/.changeset/techdocs-dirty-rivers-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Add rate limiter for concurrent execution of file uploads in AWS and Google publishers diff --git a/.changeset/tender-parrots-itch.md b/.changeset/tender-parrots-itch.md new file mode 100644 index 0000000000..00757b9b22 --- /dev/null +++ b/.changeset/tender-parrots-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +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. diff --git a/.changeset/tough-jars-share.md b/.changeset/tough-jars-share.md deleted file mode 100644 index f1b551f31e..0000000000 --- a/.changeset/tough-jars-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-tech-radar': patch ---- - -Updated example data in `README`. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495f1ff78d..9a5ef09554 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @backstage/maintainers /docs/features/techdocs @backstage/techdocs-core +/docs/features/search @backstage/techdocs-core /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core @@ -13,3 +14,4 @@ /plugins/techdocs-backend @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/techdocs-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 57827b48c9..425e6eea6f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -8,6 +8,8 @@ apis args asciidoc async +Autoscaling +autoscaling Avro backrub Balachandran @@ -36,6 +38,7 @@ Codecov codehilite Codehilite codeowners +composability composable config Config @@ -63,11 +66,13 @@ Docusaurus Dominik dtuite dzolotusky -eg Ek +etag env Env +esbuild eslint +Expedia facto failover Figma @@ -75,21 +80,23 @@ Firekube Fiverr freben Fredrik -github +Georgoulas +gitbeaker GitHub -gitlab GitLab Grafana +GraphQL graphql graphviz Gustavsson Hackathons haproxy Henneke -heroku Heroku horizontalpodautoscalers Hostname +hotspots +html http https Iain @@ -98,8 +105,8 @@ incentivised inlined inlinehilite interop -javascript -Javascript +Ioannis +JavaScript jq js json @@ -108,9 +115,11 @@ Kaewkasi Knex kubectl kubernetes +Kumar learnings lerna Lerna +Luxon magiclink mailto maintainership @@ -120,6 +129,7 @@ microsite middleware minikube Minikube +misconfiguration misgendering mkdocs Mkdocs @@ -138,10 +148,12 @@ Niklas nodegit nohoist nonces +noop npm +nvarchar nvm -oauth -Oauth +OAuth +octokit oidc Okta Oldsberg @@ -160,7 +172,6 @@ prebaked preconfigured prepack Preprarer -Prerequisities productional Protobuf proxying @@ -182,6 +193,8 @@ rollbar Rollbar Rollup Rosaceae +routable +Routable rst rsync rugvip @@ -194,7 +207,7 @@ semlas semver Serverless Sinon -smartsymobls +Sneha Snyk sourcemaps sparklines @@ -221,7 +234,6 @@ Templater templaters Templaters Thauer -theres toc tolerations Tolerations @@ -234,11 +246,13 @@ transpiled transpilation Tuite ui +unmanaged untracked upvote url utils validators +varchar Voi Wealthsimple Weaveworks diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml new file mode 100644 index 0000000000..f3fe2bbc5f --- /dev/null +++ b/.github/workflows/fossa.yml @@ -0,0 +1,44 @@ +name: FOSSA +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + # We use this to modify the generated .fossa.yml + - name: Install yq + run: sudo snap install yq + + - name: Install Fossa + run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash" + + - name: Configure Fossa + # The --option flag for fossa init does not work yet, see https://github.com/fossas/fossa-cli/issues/614 + run: | + fossa init + yq eval -i '.analyze.modules[].options.strategy = "yarn-list"' .fossa.yml + + # This deletes entries for template and example packages found within packages and plugins + # Seems like yq has a bug that causes only a subset of all matches to be deleted each run + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + + - name: Show config + run: cat .fossa.yml + + - name: Fossa Analyze + env: + # FOSSA Push-Only API Token + FOSSA_API_KEY: 9ee7e8893660832a7387dcc32377fb61 + run: fossa analyze --branch "$GITHUB_REF" diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e9f5e602a5..68b08224ac 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -8,6 +8,9 @@ jobs: build: runs-on: ubuntu-latest + outputs: + needs_release: ${{ steps.release_check.outputs.needs_release }} + strategy: matrix: node-version: [12.x, 14.x] @@ -47,6 +50,15 @@ jobs: run: yarn install --frozen-lockfile # End of yarn setup + - name: Fetch previous commit for release check + run: git fetch origin '${{ github.event.before }}' + + - name: Check if release + id: release_check + run: node scripts/check-if-release.js + env: + COMMIT_SHA_BEFORE: '${{ github.event.before }}' + - name: validate config run: yarn backstage-cli config:check @@ -82,9 +94,10 @@ jobs: # We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and # only run the build steps that are necessary for publishing release: - if: ${{ endsWith(github.event.head_commit.message, 'from backstage/changeset-release/master\n\nVersion Packages') }} needs: build + if: needs.build.outputs.needs_release == 'true' + runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 8a2fa99ed2..45182229c9 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [14.x] env: CI: true @@ -27,6 +27,9 @@ jobs: with: node-version: ${{ matrix.node-version }} + - name: verify doc links + run: node scripts/verify-links.js + # Skip caching of microsite dependencies, it keeps the global cache size # smaller, which make Windows builds a lot faster for the rest of the project. - name: yarn install diff --git a/.github/workflows/microsite-with-storybook-deploy.yml b/.github/workflows/microsite-with-storybook-deploy.yml index 22fd48789a..cf571ef4e8 100644 --- a/.github/workflows/microsite-with-storybook-deploy.yml +++ b/.github/workflows/microsite-with-storybook-deploy.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [14.x] env: CI: true @@ -55,7 +55,7 @@ jobs: run: ls microsite/build/backstage && ls microsite/build/backstage/storybook - name: Deploy both microsite and storybook to gh-pages - uses: JamesIves/github-pages-deploy-action@3.4.2 + uses: JamesIves/github-pages-deploy-action@3.7.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0dc40d2c40..846882d853 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,6 +55,7 @@ jobs: - name: tsc run: yarn tsc + - name: build run: yarn build diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index 679abe6536..99daba61da 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -9,17 +9,17 @@ on: pull_request: types: [opened, reopened, labeled, edited] -env: - MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} - jobs: assign_issue_or_pr_to_project: runs-on: ubuntu-latest name: Triage + env: + MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} steps: - name: Assign new issue to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.title, 'TechDocs') || contains(github.event.issue.title, 'techdocs') || contains(github.event.issue.title, 'Techdocs') @@ -30,6 +30,7 @@ jobs: - name: Assign new issue to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' @@ -38,6 +39,7 @@ jobs: - name: Assign new PR to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.title, 'TechDocs') || contains(github.event.pull_request.title, 'techdocs') || contains(github.event.pull_request.title, 'Techdocs') @@ -48,6 +50,7 @@ jobs: - name: Assign new PR to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' diff --git a/.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/ADOPTERS.md b/ADOPTERS.md index 3d5914d97b..363c092198 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,18 +1,21 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| Organization | Contact | Description of Use | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | diff --git a/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/README.md b/README.md index a4b21539b5..7c04672998 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) @@ -61,6 +57,6 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how ## License -Copyright 2020 © Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage +Copyright 2020-2021 © Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 diff --git a/app-config.yaml b/app-config.yaml index f6f71478d0..02afb8ac41 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -16,6 +16,10 @@ backend: credentials: true csp: connect-src: ["'self'", 'http:', 'https:'] + reading: + allow: + - host: example.com + - host: '*.mozilla.org' # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir # See README.md in the proxy-backend plugin for information on the configuration format @@ -76,7 +80,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. sentry: organization: my-company @@ -127,7 +131,16 @@ integrations: catalog: rules: - - allow: [Component, API, Group, User, Template, Location] + - allow: + - Component + - API + - Resource + - Group + - User + - Template + - System + - Domain + - Location processors: githubOrg: @@ -172,24 +185,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 + - type: file + target: ../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: ../../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: @@ -206,6 +241,14 @@ scaffolder: api: token: $env: AZURE_TOKEN + bitbucket: + api: + host: https://bitbucket.org + username: + $env: BITBUCKET_USERNAME + token: + $env: BITBUCKET_TOKEN + auth: environment: development ### Providing an auth.session.secret will enable session support in the auth-backend @@ -329,3 +372,8 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' + +kafka: + clientId: backstage + brokers: + - localhost:9092 diff --git a/catalog-info.yaml b/catalog-info.yaml index 617d01093e..7e60af5755 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -6,7 +6,7 @@ metadata: Backstage is an open-source developer portal that puts the developer experience first. annotations: github.com/project-slug: backstage/backstage - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage lighthouse.com/website-url: https://backstage.io spec: type: library diff --git a/Dockerfile b/contrib/docker/frontend-with-nginx/Dockerfile similarity index 90% rename from Dockerfile rename to contrib/docker/frontend-with-nginx/Dockerfile index 174548a90c..a444c9de83 100644 --- a/Dockerfile +++ b/contrib/docker/frontend-with-nginx/Dockerfile @@ -8,8 +8,6 @@ FROM nginx:mainline # This dockerfile requires the app to be built on the host first, as it # simply copies in the build output into the image. -# The safest way to build this image is to use `yarn docker-build:app` - RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* COPY packages/app/dist /usr/share/nginx/html diff --git a/docker/default.conf.template b/contrib/docker/frontend-with-nginx/docker/default.conf.template similarity index 100% rename from docker/default.conf.template rename to contrib/docker/frontend-with-nginx/docker/default.conf.template diff --git a/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/run.sh similarity index 100% rename from docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/run.sh diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md new file mode 100644 index 0000000000..2b8407abd2 --- /dev/null +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -0,0 +1,52 @@ +# Authenticate API requests + +The Backstage backend APIs are by default available without authentication. To avoid evil-doers from accessing or modifying data, one might use a network protection mechanism such as a firewall or an authenticating reverse proxy. For Backstage instances that are available on the Internet one can instead use the experimental IdentityClient as outlined below. + +API requests from frontend plugins include an authorization header with a Backstage identity token acquired when the user logs in. By adding a middleware that verifies said token to be valid and signed by Backstage, non-authenticated requests can be blocked with a 401 Unauthorized response. + +Note that this means Backstage will stop working for guests, as no token is issued for them. + +Caveat: as of writing this, Backstage does not refresh the identity token so eventually users will get a 401 response on API calls (not on loading the web page as only the API calls are authenticated) and have to logout/login again to get a new token. + +```typescript +// packages/backend/src/index.ts from a create-app deployment + +import { Request, Response, NextFunction } from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; + +// ... + +async function main() { + // ... + + const discovery = SingleHostDiscovery.fromConfig(config); + const identity = new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const token = IdentityClient.getBearerToken(req.headers.authorization); + req.user = await identity.authenticate(token); + next(); + } catch (error) { + res.status(401).send(`Unauthorized`); + } + }; + + const apiRouter = Router(); + // The auth route must be publically available as it is used during login + apiRouter.use('/auth', await auth(authEnv)); + // Only authenticated requests are allowed to the routes below + apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv)); + apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv)); + apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv)); + apiRouter.use(authMiddleware, notFoundHandler()); + + // ... +} +``` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 9b5d77bc7c..77b820d921 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -14,8 +14,8 @@ import { HeaderLabel, SupportButton, identityApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import ExampleFetchComponent from '../ExampleFetchComponent'; const ExampleComponent = () => { diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 6061b69e93..6992d05866 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -11,8 +11,8 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; const query = `{ diff --git a/docs/architecture-decisions/adr000-template.md b/docs/architecture-decisions/adr000-template.md new file mode 100644 index 0000000000..538afccf87 --- /dev/null +++ b/docs/architecture-decisions/adr000-template.md @@ -0,0 +1,24 @@ +--- +id: adrs-adr000 +title: ADR000: [TITLE] +description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] +--- + +# ADR000: [title] + + + +## Context + + + +## Decision + + + +## Consequences + + + + diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md index 16783367ab..872b3311a5 100644 --- a/docs/architecture-decisions/adr001-add-adr-log.md +++ b/docs/architecture-decisions/adr001-add-adr-log.md @@ -4,12 +4,16 @@ title: ADR001: Architecture Decision Record (ADR) log description: Architecture Decision Record (ADR) logs as a reference point for the team --- -| Created | Status | -| ---------- | ------ | -| 2020-04-26 | Open | +## Decision -## Decision: A decision was made to store ADRs in a log in the project repository +A decision was made to store ADRs in a log in the project repository -## Discussion: There is a need to store big decisions made in a log as a reference point for the team, help with onboarding new members and give context to others interested in the project. +## Discussion -## Risks: People stop adding ADRs to the log and context gets lost +There is a need to store big decisions made in a log as a reference point for +the team, help with onboarding new members and give context to others interested +in the project. + +## Risks + +People stop adding ADRs to the log and context gets lost diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 699f7af8ed..8523b4a111 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -4,10 +4,6 @@ title: ADR002: Default Software Catalog File Format description: Architecture Decision Record (ADR) log on Default Software Catalog File Format --- -| Created | Status | -| ---------- | ------ | -| 2020-05-17 | Open | - ## Background Backstage comes with a software catalog functionality, that you can use to track diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index d08bc6a882..878d0d701c 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -4,10 +4,6 @@ title: ADR003: Avoid Default Exports and Prefer Named Exports description: Architecture Decision Record (ADR) log on Avoid Default Exports and Prefer Named Exports --- -| Created | Status | -| ---------- | ------ | -| 2020-05-19 | Open | - ## Context When CommonJS was the primary authoring format, the best practice was to export diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 14c94c4ed1..846ca8feae 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -4,10 +4,6 @@ title: ADR004: Module Export Structure description: Architecture Decision Record (ADR) log on Module Export Structure --- -| Created | Status | -| ---------- | ------ | -| 2020-05-27 | Open | - ## Context With a growing number of exports of packages like `@backstage/core`, it is diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 34d6449c6b..83196c12e2 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -4,10 +4,6 @@ title: ADR005: Catalog Core Entities description: Architecture Decision Record (ADR) log on Catalog Core Entities --- -| Created | Status | -| ---------- | ------ | -| 2020-05-29 | Open | - ## Context We want to standardize on a few core entities that we are tracking in the diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 51dcf042da..eecb4ad87a 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -43,6 +43,15 @@ const GoodComponent = ({ text, children }: GoodProps) => ( {children} ); + +/* Or as a shorthand, if no specifc child type is required */ +type GoodProps = PropsWithChildren<{ text: string }>; +const GoodComponent = ({ text, children }: GoodProps) => ( +
+
{text}
+ {children} +
+); ``` ## Consequences diff --git a/docs/architecture-decisions/adr010-luxon-date-library.md b/docs/architecture-decisions/adr010-luxon-date-library.md new file mode 100644 index 0000000000..b4c1ddedba --- /dev/null +++ b/docs/architecture-decisions/adr010-luxon-date-library.md @@ -0,0 +1,38 @@ +--- +id: adrs-adr010 +title: ADR010: Use the Luxon Date Library +description: Architecture Decision Record (ADR) for Luxon Date Library +--- + +# ADR010: Use the 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/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/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg new file mode 100644 index 0000000000..af04ab63e8 --- /dev/null +++ b/docs/assets/search/architecture.drawio.svg @@ -0,0 +1,541 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + + + + +
+
+
+ App Package: <Route path="/search" element={<... />} /> +
+
+
+
+ + App Package: <Route path="/search" element={<... />}... + +
+
+ + + + +
+
+
+ @backstage/plugin-search-backend +
+
+
+
+ + @backstage/plugin-search-backend + +
+
+ + + + +
+
+
+ Other Plugins +
+ (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Plugins... + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + +
+
+
+ @backstage/plugin-search +
+
+
+
+ + @backstage/plugin-search + +
+
+ + + + + + +
+
+
+ Other Backend Plugin (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Backend Plugin (TechDocs, Catalog, Etc) + +
+
+ + + + +
+
+
+ Search Engine (Elastic, Solr, SaaS, etc.) +
+
+
+
+ + Search Engine (Elastic, Solr, SaaS, et... + +
+
+ + + + +
+
+
+ Search Engine Integration Layer +
+
+
+
+ + Search Engine Integration Layer + +
+
+ + + + + + + + + + + + +
+
+
+ + 1 2 3 + +
+
+
+
+ + 1 2 3 + +
+
+ + + + +
+
+
+ + X number of search results + +
+
+
+
+ + X number of sear... + +
+
+ + + + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + + +
+
+
+ Pass Search +
+ Term and Filters +
+ and then +
+ Return Results +
+
+
+
+ + Pass Search... + +
+
+ + + + +
+
+
+ + Search API + +
+
+
+
+ + Search API + +
+
+ + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + +
+
+
+ + Scheduler + +
+
+
+
+ + Scheduler + +
+
+ + + + +
+
+
+ + Gather Documents + +
+
+
+
+ + Gather D... + +
+
+ + + + + + + +
+
+
+ + Collate Documents +
+ Or Metadata +
+
+
+
+
+ + Collate Docum... + +
+
+ + + + +
+
+
+ + API Endpoint +
+
+
+
+
+
+ + API Endp... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + + Manage Index + +
+ Create, Remove, Replace Documents and Indices +
+
+
+
+
+ + Manage Index... + +
+
+ + + + + + + +
+
+
+ + Compile and Execute Query from Term and Filters + +
+
+
+
+ + Compile and Execut... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index 18a6c5a4f0..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..424869196d 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -61,6 +61,19 @@ 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 @@ -96,6 +109,11 @@ auth: development: clientId: $env: + saml: + entryPoint: + $env: AUTH_SAML_ENTRY_POINT + issuer: + $env: AUTH_SAML_ISSUER ... ``` 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 index c505bf8ae7..726f2a922c 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -28,6 +28,7 @@ app:diff Diff an existing app with the creation template 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 @@ -166,7 +167,7 @@ Options: ## backend:build -Scope: `backend`, `backend-plugin` +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 @@ -180,6 +181,52 @@ 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 +WORKDIR /app + +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +RUN yarn install --production --network-timeout 600000 && 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` diff --git a/docs/conf/defining.md b/docs/conf/defining.md index 358498929a..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[]; }; } ``` 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/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/index.md b/docs/features/kubernetes/index.md new file mode 100644 index 0000000000..c468fdb116 --- /dev/null +++ b/docs/features/kubernetes/index.md @@ -0,0 +1,127 @@ +--- +id: overview +title: Kubernetes +sidebar_label: Overview +description: Monitoring Kubernetes based services with the service catalog +--- + +Kubernetes in Backstage is a way to monitor your service's current status when +it is deployed on Kubernetes. + +## Configuration + +Example: + +```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/search/README.md b/docs/features/search/README.md new file mode 100644 index 0000000000..0b13b47979 --- /dev/null +++ b/docs/features/search/README.md @@ -0,0 +1,100 @@ +--- +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 V.0 ✅ | Search Frontend letting you search through the entities of the software catalog. [See V.0 Use Cases.](#backstage-search-v0) | +| Backstage Search V.1 ⌛ | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See V.1 Use Cases.](#backstage-search-v1) | +| Backstage Search V.2 ⌛ | Search Backend responsible for the indexing process of entities, and their metadata, registered to the Software Catalog. [See V.2 Use Cases.](#backstage-search-v2) | +| Backstage Search V.3 ⌛ | Standardized Search API lets you index other plugins data to the search engine of choice. [See V.3 Use Cases.](#backstage-search-v3) | + +## Use Cases + +#### Backstage Search V.0 + +- As a software engineer I should be able to navigate to a search page and + search for entities registered in the Software Catalog. +- As a software engineer I should be able to use the search input field in the + sidebar to search for entities registered in the Software Catalog. +- As a software engineer I should be able to see the number of results my search + returned. +- As a software engineer I should be able to filter on metadata (kind, + lifecycle) when I’ve performed a search. +- As a software engineer I should be able to hide the filters if I don’t need to + use them. + +#### Backstage Search V.1 + +- As a software engineer I should be able to get a match of a search on all + entity metadata (e.g. owner, name, description, kind). +- As an integrator I should not have to plug in any search engine, instead I can + use the out of the box in-memory indexing process to index entities and their + metadata registered in the Software Catalog. + +#### Backstage Search V.2 + +- As an integrator I should be able to spin up an instance of ElasticSearch. +- As an integrator I should be able to define a ElasticSearch cluster in my + app_config.yaml where my data gets indexed to. + +more to come... + +#### Backstage Search V.3 + +- As a contributor I should be able to integrate plugin data to the indexing + process of Backstage Search by using the standardized API. +- As a software engineer I should be able to search for all content (for + example, entities, metadata, documentation) in backstage search. + +more to come... + +## Search Engines Supported + +See [Backstage Search Architecture](architecture.md) to get an overview of how +the search engines are used. + +| Search Engine | Support Status | +| ------------- | -------------- | +| ElasticSearch | Not yet ❌ | + +[Reach out to us](#feedback) if you want to chat about support for more search +engines. + +## Tech Stack + +| Stack | Location | +| --------------- | ------------------------ | +| Frontend Plugin | @backstage/plugin-search | +| Backend Plugin | ⌛ | + +## Feedback + +For any questions of feedback, reach out to us in the `#search` channel of our +[Discord chatroom](https://github.com/backstage/backstage#community). + +We are still looking for feedback to improve the architecture to fit your +use-case, see +[this open issue](https://github.com/backstage/backstage/issues/4078). diff --git a/docs/features/search/architecture.md b/docs/features/search/architecture.md new file mode 100644 index 0000000000..3075719b9d --- /dev/null +++ b/docs/features/search/architecture.md @@ -0,0 +1,39 @@ +--- +id: architecture +title: Search Architecture +description: Documentation on Search Architecture +--- + +# Search Architecture + +> _This is a proposed architecture which has not been implemented yet. We are +> still looking for feedback to improve the architecture to fit your use-case, +> see [this open issue](https://github.com/backstage/backstage/issues/4078)._ + +Below you can explore the Search Architecture. Our aim with this architecture is +to support a wide variety of search engines, while providing a simple developer +experience for plugin developers, and a good out-of-the-box experience for +Backstage end-users. + +Search Architecture + +At a base-level, we want to support the following: + +- We aim to enable the capability to search across the entire Backstage + ecosystem by decoupling search from content management. +- We aim to enable the capability to deploy Backstage using any search engine, + by providing an integration and translation layer between the core search + plugin and search engine specific logic that can be extended for different + search engines. We may also introduce the ability to replace the backend API + endpoint with a custom endpoint for simpler customization. + +More advanced use-cases we hope to support with this architecture include: + +- It should be easy for any plugin to expose new content to search. (e.g. entity + metadata, documentation from TechDocs) +- It should be easy for any plugin to append relevant metadata to existing + content in search. (e.g. location (path) for TechDocs page) +- It should be easy to refine search queries (e.g. ranking, scoring, etc.) +- It should be easy to customize the search UI +- It should be easy to add search functionality to any Backstage plugin or + deployment diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index d10fb29e30..d5ad240dde 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -60,7 +60,7 @@ data from. Each entry is a structure with up to four elements: and raw. If it is not supplied, anonymous access will be used. - `apiBaseUrl` (optional): If you want to communicate using the APIv3 method with this provider, specify the base URL for its endpoint here, with no - trailing slash. Specifically when the target is github, you can leave it out + trailing slash. Specifically when the target is GitHub, you can leave it out to be inferred automatically. For a GitHub Enterprise installation, it is commonly at `https://api.` or `https:///api/v3`. - `rawBaseUrl` (optional): If you want to communicate using the raw HTTP method diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 763acd2261..2b173c4b3e 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -60,7 +60,7 @@ software catalog API. }, "spec": { "lifecycle": "production", - "owner": "artist-relations@example.com", + "owner": "artist-relations-team", "type": "website" } } @@ -84,7 +84,7 @@ metadata: 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 +131,19 @@ spec: $text: https://petstore.swagger.io/v2/swagger.json ``` +Note that to be able to read from targets that are outside of the normal +integration points such as `github.com`, you'll need to explicitly allow it by +adding an entry in the `backend.reading.allow` list. For example: + +```yml +backend: + baseUrl: ... + reading: + allow: + - host: example.com + - host: '*.examples.org' +``` + ## Common to All Kinds: The Envelope The root envelope object has the following structure. @@ -268,7 +281,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. @@ -381,7 +394,8 @@ metadata: spec: type: website lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team + system: artist-engagement-portal providesApis: - artist-api ``` @@ -427,8 +441,8 @@ The current set of well-known and common values for this field is: ### `spec.owner` [required] -The owner of the component, e.g. `artist-relations@example.com`. This field is -required. +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. In Backstage, the owner of a component is the singular entity (commonly a team) that bears ultimate responsibility for the component, and has the authority and @@ -440,25 +454,45 @@ not to be used by automated processes to for example assign authorization in runtime systems. There may be others that also develop or otherwise touch the component, but there will always be one ultimate owner. -Apart from being a string, the software catalog leaves the format of this field -open to implementers to choose. Most commonly, it is set to the ID or email of a -group of people in an organizational structure. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the component +belongs to, e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | + +### `spec.subcomponentOf` [optional] + +An [entity reference](#string-references) to another component of which the +component is a part, e.g. `spotify-ios-app`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`Component`](#kind-component) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ### `spec.providesApis` [optional] -Links APIs that are provided by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +provided by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`providesApi`, and reverse `apiProvidedBy`](well-known-relations.md#providesapi-and-apiprovidedby) | ### `spec.consumesApis` [optional] -Links APIs that are consumed by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +consumed by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`consumesApi`, and reverse `apiConsumedBy`](well-known-relations.md#consumesapi-and-apiconsumedby) | ## Kind: Template @@ -597,7 +631,8 @@ metadata: spec: type: openapi lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team + system: artist-engagement-portal definition: | openapi: "3.0.0" info: @@ -663,8 +698,8 @@ The current set of well-known and common values for this field is: ### `spec.owner` [required] -The owner of the API, e.g. `artist-relations@example.com`. This field is -required. +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. In Backstage, the owner of an API is the singular entity (commonly a team) that bears ultimate responsibility for the API, and has the authority and capability @@ -676,9 +711,18 @@ processes to for example assign authorization in runtime systems. There may be others that also develop or otherwise touch the API, but there will always be one ultimate owner. -Apart from being a string, the software catalog leaves the format of this field -open to implementers to choose. Most commonly, it is set to the ID or email of a -group of people in an organizational structure. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the API belongs to, +e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ### `spec.definition` [required] @@ -751,11 +795,11 @@ parent; the catalog supports multi-root hierarchies. Groups may however not have more than one parent. This field is an -[entity reference](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -this field points to a group in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of that group. +[entity reference](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`childOf`, and reverse `parentOf`](well-known-relations.md#parentof-and-childof) | ### `spec.children` [required] @@ -765,11 +809,11 @@ no child groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +[entity references](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`hasMember`, and reverse `memberOf`](well-known-relations.md#memberof-and-hasmember) | ## Kind: User @@ -825,23 +869,200 @@ user is not member of any groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +[entity references](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`memberOf`, and reverse `hasMember`](well-known-relations.md#memberof-and-hasmember) | ## Kind: Resource -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Resource` | + +A resource describes the infrastructure a system needs to operate, like BigTable +databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with +components and systems allows to visualize resource footprint, and create +tooling around them. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: artists-db + description: Stores artist details +spec: + type: database + owner: artist-relations-team + system: artist-engagement-portal +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `Resource`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the resource, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a resource is the singular entity (commonly a team) +that bears ultimate responsibility for the resource, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this resource belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also manage or otherwise touch the +resource, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.type` [required] + +The type of resource as a string, e.g. `database`. This field is required. There +is currently no enforced set of values for this field, so it is left up to the +adopting organization to choose a nomenclature that matches the resources used +in their tech stack. + +Some common values for this field could be: + +- `database` +- `s3-bucket` +- `cluster` + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the resource +belongs to, e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ## Kind: System -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `System` | + +A system is a collection of resources and components. The system may expose or +consume one or several APIs. It is viewed as abstraction level that provides +potential consumers insights into exposed features without needing a too +detailed view into the details of all components. This also gives the owning +team the possibility to decide about published artifacts and APIs. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: artist-engagement-portal + description: Handy tools to keep artists in the loop +spec: + owner: artist-relations-team + domain: artists +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `System`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the system, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a system is the singular entity (commonly a team) +that bears ultimate responsibility for the system, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this system belongs. It is not +to be used by automated processes to for example assign authorization in runtime +systems. There may be others that also develop or otherwise touch the system, +but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.domain` [optional] + +An [entity reference](#string-references) to the domain that the system belongs +to, e.g. `artists`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`Domain`](#kind-domain) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ## Kind: Domain -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Domain` | + +A Domain groups a collection of systems that share terminology, domain models, +business purpose, or documentation, i.e. form a bounded context. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: artists + description: Everything about artists +spec: + owner: artist-relations-team +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `Domain`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the domain, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a domain is the singular entity (commonly a team) +that bears ultimate responsibility for the domain, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this domain belongs. It is not +to be used by automated processes to for example assign authorization in runtime +systems. There may be others that also develop or otherwise touch the domain, +but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | ## Kind: Location diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index e7d253f11e..e278bc7a1a 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -94,7 +94,7 @@ The recommended way of instantiating the catalog backend classes is to use the as illustrated in the [example backend here](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/catalog.ts). We will create a new -[`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/types.ts) +[`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/processors/types.ts) subclass that can be added to this catalog builder. It is up to you where you put the code for this new processor class. For quick diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index 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..2cbb829554 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -22,7 +22,7 @@ use. # Example: metadata: annotations: - backstage.io/managed-by-location: github:http://github.com/backstage/backstage/catalog-info.yaml + backstage.io/managed-by-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml ``` The value of this annotation is a so called location reference string, that @@ -30,8 +30,8 @@ points to the source from which the entity was originally fetched. This annotation is added automatically by the catalog as it fetches the data from a registered location, and is not meant to normally be written by humans. The annotation may point to any type of generic location that the catalog supports, -so it cannot be relied on to always be specifically of type `github`, nor that -it even represents a single file. Note also that a single location can be the +so it cannot be relied on to always be specifically of type `url`, nor that it +even represents a single file. Note also that a single location can be the source of many entities, so it represents a many-to-one relationship. The format of the value is `:`. Note that the target may also @@ -40,13 +40,30 @@ expecting a two-item array out of it. The format of the target part is type-dependent and could conceivably even be an empty string, but the separator colon is always present. +### backstage.io/managed-by-origin-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/managed-by-origin-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml +``` + +The value of this annotation is a location reference string (see above). It +points to the location, whose registration lead to the creation of the entity. +In most cases, the `backstage.io/managed-by-location` and +`backstage.io/managed-by-origin-location` will be equal. They will be different +if the original location delegates to another location. A common case is, that a +location is registered as `bootstrap:bootstrap` which means that it is part of +the `app-config.yaml` of a Backstage installation. + ### backstage.io/techdocs-ref ```yaml # Example: metadata: annotations: - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage/tree/master ``` The value of this annotation is a location reference string (see above). If this diff --git a/docs/features/software-catalog/well-known-relations.md b/docs/features/software-catalog/well-known-relations.md index 54f7833d1b..6fb7ae2fea 100644 --- a/docs/features/software-catalog/well-known-relations.md +++ b/docs/features/software-catalog/well-known-relations.md @@ -48,11 +48,10 @@ where present. ### `providesApi` and `apiProvidedBy` A relation with an [API](descriptor-format.md#kind-api) entity, typically from a -[Component](descriptor-format.md#kind-component) or -[System](descriptor-format.md#kind-system). +[Component](descriptor-format.md#kind-component). -These relations express that a component or system exposes an API - meaning that -it hosts callable endpoints from which you can consume that API. +These relations express that a component exposes an API - meaning that it hosts +callable endpoints from which you can consume that API. This relation is commonly generated based on `spec.providesApis` of the component or system in question. @@ -60,11 +59,10 @@ component or system in question. ### `consumesApi` and `apiConsumedBy` A relation with an [API](descriptor-format.md#kind-api) entity, typically from a -[Component](descriptor-format.md#kind-component) or -[System](descriptor-format.md#kind-system). +[Component](descriptor-format.md#kind-component). -These relations express that a component or system consumes an API - meaning -that it depends on endpoints of the API. +These relations express that a component consumes an API - meaning that it +depends on endpoints of the API. This relation is commonly generated based on `spec.consumesApis` of the component or system in question. @@ -91,3 +89,18 @@ A membership relation, typically for [Users](descriptor-format.md#kind-user) in [Groups](descriptor-format.md#kind-group). This relation is commonly based on `spec.memberOf`. + +### `partOf` and `hasPart` + +A relation with a [Domain](descriptor-format.md#kind-domain), +[System](descriptor-format.md#kind-system) or +[Component](descriptor-format.md#kind-component) entity, typically from a +[Component](descriptor-format.md#kind-component), +[API](descriptor-format.md#kind-api), or +[System](descriptor-format.md#kind-system). + +These relations express that a component belongs to a larger component; a +component, API or resource belongs to a system; or that a system is grouped +under a domain. + +This relation is commonly based on `spec.system` or `spec.domain`. diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md index 97854c999a..5e2809e006 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -57,7 +57,7 @@ That type looks like the following: export type PublisherBase = { publish(opts: { entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; + values: TemplaterValues; directory: string; }): Promise<{ remoteUrl: string }>; }; diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index 63acd38286..37e68c4153 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..1034ffef0f 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 diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 50cadc7b27..c43d6d978d 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? @@ -45,8 +45,6 @@ about TechDocs and the philosophy in its [v2]: https://github.com/backstage/backstage/milestone/22 [v3]: https://github.com/backstage/backstage/milestone/17 - - ## Use Cases #### TechDocs V.0 @@ -110,12 +108,12 @@ providers are used. | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | -| File Storage Provider | Support Status | Track status | -| --------------------------------- | -------------- | ----------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | | -| Google Cloud Storage (GCS) | Yes ✅ | | -| Amazon Web Services (AWS) S3 | No ❌ | [#3714](https://github.com/backstage/backstage/issues/3714) | -| Azure Storage | No ❌ | | +| File Storage Provider | Support Status | +| --------------------------------- | ----------------------------------------------------------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 7c35fbc943..92cd78b071 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -123,6 +123,20 @@ 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 _Ideas here are far fetched and not in the project's milestone for near future @@ -142,12 +156,11 @@ Status of all the features mentioned above. - Basic setup with techdocs-backend file server as storage. - Basic setup with cloud storage solution. - -**Work in progress 🚧** - - `techdocs-cli` is able to generate docs in CI/CD environment. - `techdocs-cli` is able to publish docs site to any storage. +**Work in progress 🚧** + **Not implemented yet ❌** - `techdocs-backend` integration with Backstage access control management. diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index 26254a7e12..408f092ebd 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -1,8 +1,8 @@ --- id: concepts title: Concepts -description: Documentation on concepts that are introduced with -Spotify's docs-like-code solution in Backstage +# prettier-ignore +description: Documentation on concepts that are introduced with Spotify's docs-like-code solution in Backstage --- This page describes concepts that are introduced with Spotify's docs-like-code diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index b3cb349778..1580abe69e 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -1,8 +1,8 @@ --- id: configuration title: TechDocs Configuration Options -description: - Reference documentation for configuring TechDocs using app-config.yaml +# prettier-ignore +description: Reference documentation for configuring TechDocs using app-config.yaml --- Using the `app-config.yaml` in the Backstage app, you can configure TechDocs @@ -13,18 +13,15 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. requestUrl: http://localhost:7000/api/techdocs - # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware # to serve files from either a local directory or an External storage provider. storageUrl: http://localhost:7000/api/techdocs/static/docs - # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running @@ -34,7 +31,6 @@ techdocs: generators: techdocs: 'docker' - # techdocs.builder can be either 'local' or 'external. # If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage # and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture. @@ -44,30 +40,48 @@ techdocs: builder: 'local' - # techdocs.publisher is used to configure the Storage option, whether you want to use the local filesystem to store generated docs # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - - # techdocs.publisher.type can be - 'local' or 'googleGcs' (awsS3, azureStorage, etc. to be available as well). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future). # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. + # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. type: 'local' - # Required when techdocs.publisher.type is set to 'googleGcs'. Skip otherwise. googleGcs: - # An API key is required to write to a storage bucket. + # (Required) Cloud Storage Bucket Name + bucketName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage bucket. + # If missing, GOOGLE_APPLICATION_CREDENTIALS environment variable will be used. + # https://cloud.google.com/docs/authentication/production credentials: - $file: '/path/to/google_application_credentials.json', + $file: '/path/to/google_application_credentials.json' - # Your GCP Project ID where the Cloud Storage Bucket is hosted. - projectId: 'gcp-project-id' + # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. - # Cloud Storage Bucket Name - bucketName: 'techdocs-storage', + 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 ``` diff --git a/docs/features/techdocs/configuring-ci-cd.md b/docs/features/techdocs/configuring-ci-cd.md new file mode 100644 index 0000000000..5841a02fa8 --- /dev/null +++ b/docs/features/techdocs/configuring-ci-cd.md @@ -0,0 +1,98 @@ +--- +id: configuring-ci-cd +title: Configuring CI/CD to generate and publish TechDocs sites +# prettier-ignore +description: Configuring CI/CD to generate and publish TechDocs sites to cloud storage +--- + +In the [Recommended deployment setup](./architecture.md#recommended-deployment), +TechDocs reads the static generated documentation files from a cloud storage +bucket (GCS, AWS S3, etc.). The documentation site is generated on the CI/CD +workflow associated with the repository containing the documentation files. This +document explains the steps needed to generate docs on CI and publish to a cloud +storage using [`techdocs-cli`](https://github.com/backstage/techdocs-cli). + +The steps here target all kinds of CI providers (GitHub Actions, CircleCI, +Jenkins, etc.). Specific tools for individual providers will also be made +available here for simplicity (e.g. a GitHub Actions runner, CircleCI orb, +etc.). + +A summary of the instructions below looks like this - + +```sh +# This is an example script + +# Prepare +REPOSITORY_URL='https://github.com/org/repo' +git clone $REPOSITORY_URL +cd repo + +# Generate +npx @techdocs/cli generate + +# Publish +npx @techdocs/cli publish --publisher-type awsS3 --storage-name --entity +``` + +That's it! + +Take a look at +[`techdocs-cli` README](https://github.com/backstage/techdocs-cli) for the +complete command reference, details, and options. + +## 1. Setup a workflow + +The TechDocs workflow should trigger on CI when any changes are made in the +repository containing the documentation files. You can be specific and configure +the workflow to be triggered only when files inside the `docs/` directory or +`mkdocs.yml` are changed. + +## 2. Prepare step + +The first step on the CI is to clone your documentation source repository in a +working directory. This is almost always the first step in most CI workflows. + +On GitHub Actions, you can add a step + +[`- uses: actions@checkout@v2`](https://github.com/actions/checkout). + +On CircleCI, you can add a special +[`checkout`](https://circleci.com/docs/2.0/configuration-reference/#checkout) +step. + +Eventually we are trying to do a `git clone `. + +## 3. Generate step + +Install [`npx`](https://www.npmjs.com/package/npx) to use it for running +`techdocs-cli`. Or you can install using `npm install -g @techdocs/cli`. + +We are going to use the +[`techdocs-cli generate`](https://github.com/backstage/techdocs-cli#generate-techdocs-site-from-a-documentation-project) +command in this step. + +```sh +npx @techdocs/cli generate --no-docker --source-dir PATH_TO_REPO --output-dir ./site +``` + +`PATH_TO_REPO` should be the location in the file path where the prepare step +above clones the repository. + +## 4. Publish step + +Depending on your cloud storage provider (AWS, Google Cloud, or Azure), set the +necessary authentication environment variables. + +- [Google Cloud authentication](https://cloud.google.com/storage/docs/authentication#libauth) +- [AWS authentication](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) + +And then run the +[`techdocs-cli publish`](https://github.com/backstage/techdocs-cli#publish-generated-techdocs-sites) +command. + +```sh +npx @techdocs/cli publish --publisher-type --storage-name --entity --directory ./site +``` + +The updated TechDocs site built in this workflow is now ready to be served by +the TechDocs plugin in your Backstage app. diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 38f452dcf3..187fe97aed 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) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md new file mode 100644 index 0000000000..b32ff40589 --- /dev/null +++ b/docs/features/techdocs/how-to-guides.md @@ -0,0 +1,54 @@ +--- +id: how-to-guides +title: TechDocs "HOW TO" guides +sidebar_label: "HOW TO" guides +description: TechDocs "HOW TO" guides related to TechDocs +--- + +## How to use URL Reader in TechDocs Prepare step? + +If TechDocs is configured to generate docs, it will first download the +repository associated with the `backstage.io/techdocs-ref` annotation defined in +the Entity's `catalog-info.yaml` file. This is also called the +[Prepare](./concepts.md#techdocs-preparer) step. + +There are two kinds of preparers or two ways of downloading these source files + +- Preparer 1: Doing a `git clone` of the repository (also known as Common Git + Preparer) +- Preparer 2: Downloading an archive.zip or equivalent of the repository (also + known as URL Reader) + +If `backstage.io/techdocs-ref` is equal to any of these - + +1. `github:https://githubhost.com/org/repo` +2. `gitlab:https://gitlabhost.com/org/repo` +3. `bitbucket:https://bitbuckethost.com/project/repo` +4. `azure/api:https://azurehost.com/org/project` + +Then Common Git Preparer will be used i.e. a `git clone`. But the URL Reader is +a much faster way to do this step. Convert the `backstage.io/techdocs-ref` +values to the following - + +1. `url:https://githubhost.com/org/repo/tree/` +2. `url:https://gitlabhost.com/org/repo/tree/` +3. `url:https://bitbuckethost.com/project/repo/src/` +4. `url:https://azurehost.com/organization/project/_git/repository` + +Note that you can also provide a path to a non-root directory inside the +repository which contains the `docs/` directory. + +e.g. +`url:https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/examples/documented-component` + +### Why is URL Reader faster than a git clone? + +URL Reader uses the source code hosting provider to download a zip or tarball of +the repository. The archive does not have any git history attached to it. Also +it is a compressed file. Hence the file size is significantly smaller than how +much data git clone has to transfer. + +Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 +minutes which means they will not be re-built if new changes are made within 30 +minutes. This cache invalidation will be replaced by commit timestamp based +implementation very soon. diff --git a/docs/features/techdocs/troubleshooting.md b/docs/features/techdocs/troubleshooting.md index 3a9b2fcfd0..e6efefc578 100644 --- a/docs/features/techdocs/troubleshooting.md +++ b/docs/features/techdocs/troubleshooting.md @@ -5,6 +5,53 @@ sidebar_label: Troubleshooting description: Troubleshooting for TechDocs --- -- TechDocs will fail to clone your docs if you have a git config which overrides - the `https` protocol with `ssh` or something else. Make sure to remove your - git config locally when you try TechDocs. +## Failure to clone + +TechDocs will fail to clone your docs if you have a git config which overrides +the `https` protocol with `ssh` or something else. Make sure to remove your git +config locally when you try TechDocs. + +## MkDocs Build Errors + +Using the [TechDocs CLI](https://github.com/backstage/techdocs-cli), you can +troubleshoot MkDocs build issues locally. Note this requires you have Docker +available to launch images. First, `git clone` the target repository locally, +then in the root of the repository, run: + +``` +npx @techdocs/cli serve +``` + +For example, if you have forgotten to put an MkDocs configuration file in your +repo, the resulting error will be: + +``` +npx: installed 278 in 9.089s +[techdocs-preview-bundle] Running local version of Backstage at http://localhost:3000 +INFO - Building documentation... + +Config file '/content/mkdocs.yml' does not exist. +``` + +When it works, a local copy of both Backstage and your site will be launched +locally: + +``` +npx: installed 278 in 9.682s +[techdocs-preview-bundle] Running local version of Backstage at http://localhost:3000 +INFO - Building documentation... +WARNING - Config value: 'dev_addr'. Warning: The use of the IP address '0.0.0.0' + suggests a production environment or the use of a proxy to connect to the MkDocs + server. However, the MkDocs' server is intended for local development purposes only. + Please use a third party production-ready server instead. +INFO - Cleaning site directory +DEBUG - Successfully imported extension module "plantuml_markdown". +DEBUG - Successfully loaded extension "plantuml_markdown.PlantUMLMarkdownExtension". +INFO - Documentation built in 0.23 seconds +[I 210115 19:00:45 server:335] Serving on http://0.0.0.0:8000 +INFO - Serving on http://0.0.0.0:8000 +[I 210115 19:00:45 handlers:62] Start watching changes +INFO - Start watching changes +[I 210115 19:00:45 handlers:64] Start detecting changes +INFO - Start detecting changes +``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index c7b5ec9a9f..21206dede5 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -30,20 +30,35 @@ techdocs: type: 'googleGcs' ``` -**2. GCP (Google Cloud Platform) Project** +**2. Create a GCS Bucket** -Create or choose a dedicated GCP project. Set -`techdocs.publisher.googleGcs.projectId` to the project ID. +Create a dedicated Google Cloud Storage bucket for TechDocs sites. +techdocs-backend will publish documentation to this bucket. TechDocs will fetch +files from here to serve documentation in Backstage. Note that the bucket names +are globally unique. + +Set the config `techdocs.publisher.googleGcs.bucketName` in your +`app-config.yaml` to the name of the bucket you just created. ```yaml techdocs: publisher: type: 'googleGcs' - googleGcs: - projectId: 'gcp-project-id' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' ``` -**3. Service account API key** +**3a. (Recommended) Authentication using environment variable** + +The GCS Node.js client will automatically use the environment variable +`GOOGLE_APPLICATION_CREDENTIALS` to authenticate with Google Cloud. It might +already be set in Compute Engine, Google Kubernetes Engine, etc. Read +https://cloud.google.com/docs/authentication/production for more details. + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. Create a new Service Account and a key associated with it. In roles of the service account, use "Storage Admin". @@ -65,31 +80,118 @@ techdocs: publisher: type: 'googleGcs' googleGcs: - projectId: 'gcp-project-id' + bucketName: 'name-of-techdocs-storage-bucket' credentials: $file: '/path/to/google_application_credentials.json' ``` -**4. GCS Bucket** - -Create a dedicated bucket for TechDocs sites. techdocs-backend will publish -documentation to this bucket. TechDocs will fetch files from here to serve -documentation in Backstage. - -Set the name of the bucket to `techdocs.publisher.googleGcs.bucketName`. +Note: If you are finding it difficult to make the file +`google_application_credentials.json` available on a server, you could use the +file's content and set as an environment variable. And then use ```yaml techdocs: publisher: type: 'googleGcs' googleGcs: - projectId: 'gcp-project-id' + bucketName: 'name-of-techdocs-storage-bucket' credentials: - $file: '/path/to/google_application_credentials.json' + $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' ``` -**5. That's it!** +**3a. (Recommended) Setup authentication the AWS way, using environment +variables** -Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to -store the static generated documentation files. +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). + +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 v3 Node.js client for authentication. +[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) + +If the environment variables are missing, the AWS SDK tries to read the +`~/.aws/credentials` file for credentials. +[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html) + +Note that the region of the bucket has to be set for the AWS SDK to work. +[See this](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html). + +**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/v3/developer-guide/getting-your-credentials.html). + +Note: If you are using Amazon EC2 instance to deploy Backstage, you do not need +to obtain the access keys separately. They can be made available in the +environment automatically by defining appropriate IAM role with access to the +bucket. Read more +[here](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html#use-roles). + +**4. That's it!** + +Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read +the static generated documentation files. When you start the backend of the app, +you should be able to see +`techdocs info Successfully connected to the AWS S3 bucket` in the logs. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 52ea90db5b..0663d3faa1 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -74,6 +74,22 @@ those plugins in your backend. This is because the transformation of backend module tree stops whenever a non-local package is encountered, and from that point node will `require` packages directly for that entire module subtree. +Type checking can also have issues when linking in external packages, since the +linked in packages will use the types in the external project and dependency +version mismatches between the two projects may cause errors. To fix any of +those errors you need to sync versions of the dependencies in the two projects. +A simple way to do this can be to copy over `yarn.lock` from the external +project and run `yarn install`, although this is quite intrusive and can cause +other issues in existing projects, so use this method with care. It can often be +best to simply ignore the type errors, as app serving will work just fine +anyway. + +Another issue with type checking is that the incremental type cache doesn't +invalidate correctly for the linked in packages, causing type checking to not +reflect changes made to types. You can work around this by either setting +`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the +types cache folder `dist-types` before running `yarn tsc`. + ### Troubleshooting The create app command doesn't always work as expected, this is a collection of diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index cfd2e903d9..65d9947dca 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -13,10 +13,10 @@ 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 +$ docker run --rm -it -p 7000:7000 -e NODE_ENV=development example-backend:latest ``` -Then open http://localhost/ on your browser. +Then open http://localhost:7000 on your browser. ## Heroku diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index c5275114ce..b5ff3885b3 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -1,8 +1,8 @@ --- id: development-environment title: Development Environment -description: Documentation on how to get set up for doing development on -the Backstage repository +# prettier-ignore +description: Documentation on how to get set up for doing development on the Backstage repository --- This section describes how to get set up for doing development on the Backstage diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000..e5a9882909 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,21 @@ +--- +id: glossary +title: Backstage Glossary +# prettier-ignore +description: List of all the terms, abbreviations, and phrases used in Backstage, together with their explanations. +--- + +The Backstage Glossary lists all the terms, abbreviations, and phrases used in +Backstage, together with their explanations. We encourage you to use the +terminology below for clarity and consistency when discussing Backstage. + +### Backstage User Profiles + +There are three main user profiles for Backstage: the integrator, the +contributor, and the software engineer. + +| Term | Explanation | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Integrator | The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. | +| Contributor | The **contributor** adds functionality to the app by writing plugins. | +| Software Engineer | The **software engineer** uses the app's functionality and interacts with its plugins. In practice, this profile covers the various roles that help deliver software, from the Software Engineer themselves, to Designers, Data Scientists, Product Owners, Engineering Managers, etc. | diff --git a/docs/overview/adopting.md b/docs/overview/adopting.md index 0d404d5cb4..152be21a2c 100644 --- a/docs/overview/adopting.md +++ b/docs/overview/adopting.md @@ -1,8 +1,8 @@ --- id: adopting title: Strategies for adopting -description: Documentation on some general best practices that have been key -to Backstage's success inside Spotify +# prettier-ignore +description: Documentation on some general best practices that have been key to Backstage's success inside Spotify --- This document outlines some general best practices that have been key to diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index d8069c8665..f2f2bc72c8 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -185,17 +185,6 @@ separate Docker images. ![Boxes around the architecture to indicate how it is containerised](../assets/architecture-overview/containerised.png) -The frontend container can be built with a provided command. - -```bash -yarn install -yarn tsc -yarn run docker-build:app -``` - -Running this will simply generate a Docker container containing the contents of -the UIs `dist` directory. - The backend container can be built by running the following command: ```bash diff --git a/docs/overview/background.md b/docs/overview/background.md index aca46614c2..0c22396e72 100644 --- a/docs/overview/background.md +++ b/docs/overview/background.md @@ -1,8 +1,8 @@ --- id: background title: The Spotify Story -description: Backstage was born out of necessity at Spotify. We found that as we grew, our -infrastructure was becoming more fragmented, our engineers less productive. +# prettier-ignore +description: Backstage was born out of necessity at Spotify. We found that as we grew, our infrastructure was becoming more fragmented, our engineers less productive. --- Backstage was born out of necessity at Spotify. We found that as we grew, our diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index b0c031689d..cd319c4ade 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -8,9 +8,9 @@ description: Roadmap of Backstage Project > Backstage is currently under rapid development. This means that you can expect > APIs and features to evolve. It is also recommended that teams who adopt -> Backstage today upgrade their installation as new -> [releases](https://github.com/backstage/backstage/releases) become available, -> as Backwards compatibility is not yet guaranteed. +> Backstage today [upgrade their installation](../cli/commands.md#versionsbump) +> as new [releases](https://github.com/backstage/backstage/releases) become +> available, as Backwards compatibility is not yet guaranteed. ## Phases @@ -57,8 +57,10 @@ guidelines to get started. see and manage their services running in K8s, regardless if that's locally, in AWS, GCS, Azure, or elsewhere. -- **Global search** - Extend the basic search functionality currently available - in the Backstage Service Catalog to become a global search experience. +- **[Search platform](../features/search/README.md)** - Evolve the basic search + functionality currently available into a platform that **a)** enables search + across the software catalog, TechDocs, and any other information exposed by + plugins, and **b)** supports a variety of search engine technologies. - **[Software Templates V2](https://github.com/backstage/backstage/issues/2771)** - Expand the templates to make the steps more composable by adding the ability diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index c53193f063..3304913f87 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -1,9 +1,8 @@ --- id: stability-index title: Stability Index -description: - An overview of the commitment to stability for different parts of the - Backstage codebase. +# prettier-ignore +description: An overview of the commitment to stability for different parts of the Backstage codebase. --- ## Overview @@ -291,7 +290,7 @@ Stability: `1`. There are plans to rework parts of the Processor interface. ### `catalog-graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) -Provides the catalog schema and resolvers for the graphql backend. +Provides the catalog schema and resolvers for the GraphQL backend. Stability: `0`. Under heavy development and subject to change. 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..a496217875 --- /dev/null +++ b/docs/plugins/composability.md @@ -0,0 +1,585 @@ +--- +id: composability +title: Composability System Migration +# prettier-ignore +description: Documentation and migration instructions for new composability APIs. +--- + +## Summary + +This page describes the new composability system that was recently introduced in +Backstage, and it does so from the perspective of the existing patterns and +APIs. As the new system is solidified and existing code is ported, this page +will be removed and replaced with a more direct description of the composability +system. For now, the primary purpose of this documentation is to aid in the +migration of existing plugins, but it does cover the migration of apps as well. + +The core principle of the new composability system is that plugins should have +clear boundaries and connections. It should isolate crashes within a plugin, but +allow navigation between them. It should allow for plugins to be loaded only +when needed, and enable plugins to provide extension points for other plugins to +build upon. The composability system is also built with an app-first mindset, +prioritizing simplicity and clarity in the app over that in the plugins and core +APIs. + +The new composability system isn't a single new API surface. It is a collection +of patterns, primitives, new APIs, and old APIs used in new ways. At the core is +the new concept of extensions, which are exported by plugins for use in the app. +There is also a new primitive called component data, which assists in the +conversion to a more declarative app. The `RouteRef`s now have a clear purpose +as well, and can be used route to pages in a flexible way. + +## New Concepts + +This section is a brief look into all the new and updated concepts that were put +in place to support the new composability system. + +### Component Data + +Component data is a new composability primitive that is introduced as a way to +provide a new data dimension for React components. Data is attached to React +components using a key, and is then readable from any JSX elements created with +those components, using the same key, as illustrated by the following example: + +```tsx +const MyComponent = () =>

This is my component

; +attachComponentData(MyComponent, 'my.data', 5); + +const element = ; +const myData = getComponentData(element, 'my.data'); +// myData === 5 +``` + +The purpose of component data is to provide a method for embedding data that can +be inspected before rendering elements. Element inspection is a pattern that is +quite common among React libraries, and used for example by `react-router` and +`material-ui` to discover properties of the child elements before rendering. +Although in those libraries only the element type and props are typically +inspected, while our component data adds more structured access and simplifies +evolution by allowing for multiple different versions of a piece of data to be +used and interpreted at once. + +The initial use-case for component data is to support route and plugin discovery +through elements in the app. Through this we allow for the React element tree in +the app to be the source of truth, both for which plugins are used, as well as +all top-level plugin routes in the app. The use of component data is not limited +to these use-cases though, as it can be used as a primitive to create new +abstractions as well. + +### Extensions + +Extensions are what plugins export for use in an app. Most typically they are +React components, but in practice they can be any kind of JavaScript value. They +are created using `create*Extension` functions, and wrapped with +`plugin.provide()` in order to create the actual exported extension. + +The extension type is a simple one: + +```ts +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; +``` + +The power of extensions comes from the ability of various actors to hook into +their usage. The creation and plugin wrapping is controlled by whoever owns the +creation function, the Backstage core is able to hook into the process of +exposing the extension outside the plugin, and in the end the app controls the +usage of the extension. + +The Backstage core API currently provides two different types of extension +creators, `createComponentExtension`, and `createRoutableExtension`. Component +extensions are plain React component with no particular requirements, for +example a card for an entity overview page. The component will be exported more +or less as is, but is wrapped to provide things like an error boundary, lazy +loading, and a plugin context. + +Routable extensions build on top of component extensions and are used for any +component that should be rendered at a specific route path, such as top-level +pages or entity page tab content. When creating a routable extension you need to +supply a `RouteRef` as `mountPoint`. The mount point will be the handle of the +component for the outside world, and is used by other components and plugins +that wish to link to the routable component. + +As of now there are only two extension creation functions, but it is possible to +add more of them in the future, both in the core library and in plugins that +wish to provide an extension point for other plugins to build upon. Extensions +are also not tied to React, and can both be used to model generic JavaScript +concepts, as well as potentially bridge to rendering libraries and web +frameworks other than React. + +### Extensions from a Plugin's Point of View + +Extensions are one of the primary methods to traverse the plugin boundary, and +the way that plugins provide concrete content for use within an app. They +replace existing component export concepts such as `Router` or `*Card`s for +display on entity overview pages. + +It is recommended to create the exported extensions either in the top-level +`plugin.ts` file, or in a dedicated `extensions.ts` (or `.tsx`) file. That file +should not contain the bulk of the implementation though, and in fact, if the +extension is a React component it is recommended to lazy-load the actual +component. Component extensions support lazy loading out of the box using the +`lazy` component declaration, for example: + +```ts +export const EntityFooCard = plugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/FooCard').then(m => m.FooCard), + }, + }), +); +``` + +Routable extensions even enforce lazy loading, as it is the only way to provide +a component: + +```ts +export const FooPage = plugin.provide( + createRoutableExtension({ + component: () => import('./components/FooPage').then(m => m.FooPage), + mountPoint: fooPageRouteRef, + }), +); +``` + +### Using Extensions in an App + +Right now all extensions are modelled as React components. The usage of these +extension is like regular usage of any React components, with one important +difference. Extensions must all be part of a single React element tree spanning +from the root `AppProvider`. + +For example, the following app code does **NOT** work: + +```tsx +const AppRoutes = () => ( + + } /> + } /> + +); + +const App = () => ( + + + + + + + +); +``` + +But in this case it is simple to fix! Simply be sure to not create any +intermediate components in the app, for example like this: + +```tsx +const appRoutes = ( + + } /> + } /> + +); + +const App = () => ( + + + {appRoutes} + + +); +``` + +### New Routing System + +A big piece of what is enabled by moving over to this new composability system +is to make `RouteRef`s useful. The `RouteRef`s no longer have their own path, in +fact the only required parameter is currently a `title`. Instead of assigning a +path to each `RouteRef` and possibly overriding these paths in the app, the +concrete `path` for each `RouteRef` is discovered based on the element tree in +the app. Let's consider the following example: + +```tsx +const appRoutes = ( + + } /> + } /> + +); +``` + +We'll assume that `FooPage` and `BarPage` are routable extensions, exported by +the `fooPlugin` and `barPlugin` respectively. Since the `FooPage` is a routable +extension it has a `RouteRef` assigned as its mount point, which we'll refer to +as `fooPageRouteRef`. + +Given the above example, the `fooPageRouteRef` will be associated with the +`'/foo'` route. The path is no longer accessible via the `path` property of the +`RouteRef` though, as the routing structure is tied to the app's react tree. We +instead use the new `useRouteRef` hook if we want to create a concrete link to +the page. The `useRouteRef` hook takes a single `RouteRef` as its only +parameter, and returns a function that is called to create the URL. For example +like this: + +```tsx +const MyComponent = () => { + const fooRoute = useRouteRef(fooPageRouteRef); + return Link to Foo; +}; +``` + +Now let's assume that we want to link from the `BarPage` to the `FooPage`. +Before the introduction of the new composability system, we would do this by +importing the `fooPageRouteRef` exported by the `fooPlugin`. This created an +unnecessary dependency on the plugin, and also provided little flexibility in +allowing the app to tie plugins together, with the links instead being dictated +by the plugins themselves. To solve this, we introduce `ExternalRouteRef`s. Much +like regular route references, they can be passed to `useRouteRef` to create +concrete URLs, but they can not be used as mount points in routable component +and instead have to be associated with a target route using route bindings in +the app. + +We create a new `ExternalRouteRef` inside the `barPlugin`, using a neutral name +that describes its role in the plugin rather than a specific plugin page that it +might be linking to, allowing the app to decide the final target. If the +`BarPage` for example wants to link to an external page in the header, it might +declare an `ExternalRouteRef` similar to this: + +```ts +const headerLinkRouteRef = createExternalRouteRef(); +``` + +### Binding External Routes in the App + +The association of external routes is controlled by the app. Each +`ExternalRouteRef` of a plugin should be bound to an actual `RouteRef`, usually +from another plugin. The binding process happens once at app startup, and is +then used through the lifetime of the app to help resolve concrete route paths. + +Using the above example of the `BarPage` linking to the `FooPage`, we might do +something like this in the app: + +```ts +createApp({ + bindRoutes({ bind }) { + bind(barPlugin.externalRoutes, { + headerLink: fooPlugin.routes.root, + }); + }, +}); +``` + +Given the above binding, using `useRouteRef(headerLinkRouteRef)` within the +`barPlugin` will let us create a link to whatever path the `FooPage` is mounted +at. + +Note that we are not importing and using the `RouteRef`s directly in the app, +and instead rely on the plugin instance to access routes of the plugins. This is +a new convention that was introduced to provide better namespacing and +discoverability of routes, as well as reduce the number of separate exports from +each plugin package. The route references would be supplied to `createPlugin` +like this: + +```ts +// In foo-plugin +export const fooPlugin = createPlugin({ + routes: { + root: fooPageRouteRef, + }, + ... +}) + +// In bar-plugin +export const barPlugin = createPlugin({ + externalRoutes: { + headerLink: headerLinkRouteRef, + }, + ... +}) +``` + +Also note that you almost always want to create the route references themselves +in a different file than the one that creates the plugin instance, for example a +top-level `routes.ts`. This is to avoid circular imports when you use the route +references from other parts of the same plugin. + +### Parameterized Routes + +A new addition to `RouteRef`s is the possibility of adding named and typed +parameters. Parameters are declared at creation, and will enforce presence of +the parameters in the path in the app, and require them as a parameter when +using `useRouteRef`. + +The following is an example of creation and usage of a parameterized route: + +```tsx +// Creation of a parameterized route +const myRouteRef = createRouteRef({ + title: 'My Named Route', + params: ['name'] +}) + +// In the app, where MyPage is a routable extension with myRouteRef set as mountPoint +}/> + +// Usage within a component +const myRoute = useRouteRef(myRouteRef) +return ( +
+ A + B +
+) +``` + +It is currently not possible to have parameterized `ExternalRouteRef`s, or to +bind an external route to a parameterized route, although this may be added in +the future if needed. + +### New Catalog Components + +The established pattern for selecting what plugins should be available on each +catalog page is to use custom components in the app, with logic embedded in the +render function. Typically this takes form as a component that either receives +the entity via props or uses the `useEntity` hook to retrieve the selected +entity. A `switch` or `if` / `else if` chain is then used to select what +children should be rendered based on information in the entity. + +This pattern will no longer work with the new composability system, and in +general is very difficult to build any form of declarative model around, as it +depends on runtime execution. To help replace existing code, a new +`EntitySwitch` component has been added to the `@backstage/catalog` plugin, +which grabs the selected entity from a context, and selects at most one element +to render using a list of `EntitySwitch.Case` children. + +For example, if you want all entities of kind `"Template"` to be rendered with a +`MyTemplate` component, and all other entities to be rendered with a `MyOther` +component, you would do the following: + +```tsx + + + + + + + + + + +// Shorter form if desired: + + }/> + }/> + +``` + +The `EntitySwitch` component will render the children of the first +`EntitySwitch.Case` that returns `true` when the selected entity is passed to +the function of the `if` prop. If none of the cases match, no children will be +rendered, and if a case doesn't specify an `if` filter function, it will always +match. The `if` property is simply a function of the type +`(entity: Entity) => boolean`, for example, `isKind` can be implemented like +this: + +```ts +function isKind(kind: string) { + return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase(); +} +``` + +The `@backstage/catalog` plugin provides a couple of built-in conditions, +`isKind`, `isComponentType`, and `isNamespace`. + +In addition to the `EntitySwitch` component, the catalog plugin also exports a +new `EntityLayout` component. It is a tweaked version and replacement for the +`EntityPageLayout` component, and is introduced more in depth in the app +migration section below. + +## Porting Existing Plugins + +There are a couple of high-level steps to porting an existing plugin to the new +composability system: + +- Remove usage of `router.addRoute` or `router.registerRoute` within + `createPlugin`, and export the page components as routable extensions instead. +- Switch any `Router` export to instead be a routable extension. +- Change any plain component exports, such as catalog overview cards, to be + component extensions. +- Stop exporting `RouteRef`s and instead pass them to `createPlugin`. +- Stop accepting `RouteRef`s as props or importing them from other plugins, + instead create an `ExternalRouteRef` as a replacement, and pass it to + `createPlugin.` +- Rename any other exported symbols according to the naming pattern table below. + +Note that removing the existing exports and configuration is a breaking change +in any plugin. If backwards compatibility is needed the existing code be +deprecated while making the new additions, to then be removed at a later point. + +### Naming Patterns + +Many export naming patterns have been changed to avoid import aliases and to +clarify intent. Refer to the following table to formulate the new name: + +| Description | Existing Pattern | New Pattern | Examples | +| -------------------- | -------------------------- | --------------- | ---------------------------------------------- | +| Top-level Pages | Router | \*Page | CatalogIndexPage, SettingsPage, LighthousePage | +| Entity Tab Content | Router | Entity\*Content | EntityJenkinsContent, EntityKubernetesContent | +| Entity Overview Card | \*Card | Entity\*Card | EntitySentryCard, EntityPagerDutyCard | +| Entity Conditional | isPluginApplicableToEntity | is\*Available | isPagerDutyAvailable, isJenkinsAvailable | +| Plugin Instance | plugin | \*Plugin | jenkinsPlugin, catalogPlugin | + +## Porting Existing Apps + +The first step of porting any app is to replace the root `Routes` component with +`FlatRoutes` from `@backstage/core`. As opposed to the `Routes` component, +`FlatRoutes` only considers the first level of `Route` components in its +children, and provides any additional children to the outlet of the route. It +also removes the need to append `"/*"` to paths, as it is added automatically. + +```diff +const AppRoutes = () => ( +- ++ + ... +- } /> ++ } /> + ... +- ++ +); +``` + +The next step should be to switch from using `EntityPageLayout` to +`EntityLayout`, as this can also be done without waiting for plugins to be +ported. You should also replace the top-level `Router` from the catalog plugin +with the separate `CatalogIndexPage` and `CatalogEntityPage` extensions that +have been added to the catalog: + +```diff +-} +-/> ++} /> ++} ++> ++ ++ +``` + +At that point you should flatten out the element tree as much as possible in the +app, removing any intermediate components. At the top level this should usually +be straightforward, but when reaching the catalog entity pages you may need to +wait for some plugins to be migrated. This is because it is no longer possible +to pass in the selected entity through component props, and it should be picked +up from context inside the plugin instead. See the sections below for how to +carry out migrations of some common entity page patterns. + +Once the app element tree doesn't contain any intermediate components, and all +plugin imports have been switched to extensions rather than plain components, +the app has been fully ported. + +### Switching from EntityPageLayout to EntityLayout + +The existing `EntityPageLayout` is replaced by the new `EntityLayout` component, +which has a slightly different pattern for expressing the contents and paths. + +Porting from the old to the new API is just a matter of moving some things +around. For example, given the following existing code: + +```tsx + + } + /> + } + /> + } + /> + +``` + +It would be ported to this: + +```tsx + + + + + + + + + + + + + +``` + +In addition to the renaming, the `element` prop has been moved to `children`. +Also note that the `/*` suffix has been removed from the `"/kubernetes"` path, +as it's now added automatically. + +Usage of the `EntityLayout` component is required to be able to properly +discover routes, and so it is required to apply this change before you can start +using routable entity content extensions from plugins. + +### Porting Entity Pages + +The established pattern in the app is to use custom components in order to +select what plugin components to render for a given entity. The new +`EntitySwitch` component introduced above is what is intended to replace this +pattern, now that the entire app needs to be rendered as a single element tree. +For example, given the following existing code: + +```tsx +export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLowerCase()) { + case 'component': + return ; + case 'api': + return ; + case 'group': + return ; + case 'user': + return ; + default: + return ; + } +}; +``` + +It would be migrated to this: + +```tsx +export const entityPage = ( + + + + + + + +); +``` + +Note that for example `` has been changed to simply +`componentPage`, that is because just like the `EntityPage` component, the +`ComponentEntityPage` also needs to be ported to be an element rather a +component in a similar way. diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md new file mode 100644 index 0000000000..d3b0e36cd9 --- /dev/null +++ b/docs/plugins/github-apps.md @@ -0,0 +1,82 @@ +# Using GitHub Apps for Backend Authentication + +Backstage can be configured to use GitHub Apps for backend authentication. This +comes with advantages such as higher rate limits and that Backstage can act as +an application instead of a user or bot account. + +It also provides a much clearer and better authorization model as a opposed to +the OAuth apps and their respective scopes. + +## Caveats + +- It's not possible to have multiple Backstage GitHub Apps installed in the same + GitHub organization, to be handled by Backstage. We currently don't check + through all the registered GitHub Apps to see which ones are installed for a + particular repository. We only respect global Organization installs right now. +- App permissions is not managed by Backstage. They're created with some simple + default permissions which you are free to change as you need, but you will + need to update them in the GitHub web console, not in Backstage right now. The + permissions that are defaulted are `metadata:read` and `contents:read`. +- The created GitHub App is private by default, this is most likely what you + want for github.com but it's recommended to make your application public for + GitHub Enterprise in order to share application across your GHE organizations. + +A GitHub app created with `backstage-cli create-github-app` will have read +access by default. You have to manually update the GitHub App settings in GitHub +to grant the app more permissions if needed. + +### Using the CLI (public GitHub only) + +You can use the `backstage-cli` to create GitHub App' using a manifest file that +we provide. This gives us a way to automate some of the work required to create +a GitHub app. + +You can read more about the `backstage-cli create-github-app` method +[here](../cli/commands.md#create-github-app) + +Once you've gone through the CLI command, it should produce a `yaml` file in the +root of the project which you can then use as an `include` in your +`app-config.yaml`. You can go ahead and skip to +[here](#including-in-integrations-config) if you've got to this part. + +### GitHub Enterprise + +You have to create the GitHub Application manually using these +[instructions](https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app) +as GitHub Enterprise does not support creation of apps from manifests. + +Once the application is created you have to generate a private key for the +application it in a `yaml` file. + +The yaml file must include the following information. Please note that the +indentation for the `privateKey` is required. + +```yaml +appId: 1 +clientId: client id +clientSecret: client secret +webhookSecret: webhook secret +privateKey: | + -----BEGIN RSA PRIVATE KEY----- + ...Key content... + -----END RSA PRIVATE KEY----- +``` + +### Including in Integrations Config + +Once the credentials are stored in a yaml file generated by `create-github-app` +or manually by following the [GitHub Enterprise](#gitHub-enterprise) +instructions, they can be included in the `app-config.yaml` under the +`integrations` section. + +Please note that the credentials file is highly sensitive and should NOT be +checked into any kind of version control. Instead use your preferred secure +method of distributing secrets. + +```yaml +integrations: + github: + - host: github.com + apps: + - $include: example-backstage-app-credentials.yaml +``` diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index c5d9763f7d..109f6ed6ba 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -54,13 +54,4 @@ addRoute( Component: ComponentType, options?: RouteOptions, ): void; - -/** - * @deprecated See the `addRoute` method - */ -registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, -): void; ``` diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index efea02ee23..06fae0533e 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -7,7 +7,7 @@ description: Documentation on Publishing npm packages ## npm npm packages are published through CI/CD in the -[.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) +[`.github/workflows/master.yml`](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) workflow. Every commit that is merged to master will be checked for new versions of all public packages, and any new versions will automatically be published to npm. diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index b564fd732c..30c3bf0ecc 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,30 +210,36 @@ 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 @@ -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-router.md b/docs/reference/createPlugin-router.md index 89ee44e558..0ef5bdbd0f 100644 --- a/docs/reference/createPlugin-router.md +++ b/docs/reference/createPlugin-router.md @@ -15,15 +15,6 @@ addRoute( Component: ComponentType, options?: RouteOptions, ): void; - -/** - * @deprecated See the `addRoute` method - */ -registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, -): void; ``` ## RouteRef diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md index 93f4f9cd48..9bba0c76c6 100644 --- a/docs/reference/utility-apis/ErrorApi.md +++ b/docs/reference/utility-apis/ErrorApi.md @@ -29,7 +29,7 @@ These types are part of the API declaration, but may not be unique to this API. ### Error -Mirrors the javascript Error class, for the purpose of providing documentation +Mirrors the JavaScript Error class, for the purpose of providing documentation and optional fields.
diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md
index a30288e85e..5c8a8cd3bb 100644
--- a/docs/support/project-structure.md
+++ b/docs/support/project-structure.md
@@ -1,8 +1,8 @@
 ---
 id: project-structure
 title: Backstage Project Structure
-description:
-  Introduction to files and folders in the Backstage Project repository
+# prettier-ignore
+description: Introduction to files and folders in the Backstage Project repository
 ---
 
 Backstage is a complex project, and the GitHub repository contains many
@@ -32,10 +32,6 @@ the code.
   better control over our `yarn.lock` file and hopefully avoid problems due to
   yarn versioning differences.
 
-- [`docker/`](https://github.com/backstage/backstage/tree/master/docker) - Files
-  related to our root Dockerfile. We are planning to refactor this, so expect
-  this folder to be moved in the future.
-
 - [`contrib/`](https://github.com/backstage/backstage/tree/master/contrib) -
   Collection of examples or resources provided by the community. We really
   appreciate contributions in here and encourage them being kept up to date.
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
index 1dcca9d7e0..1c661d274c 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,26 +321,28 @@ 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({ @@ -153,12 +369,18 @@ const app = createApp({ }); ``` -7. Start the backend and frontend as before +After finishing setting up one (or multiple) authentication providers defined +above you can start the backend and frontend as before When the browser loads, you should be presented with a login page for GitHub. Login as usual with your GitHub account. If this is your first time, you will be asked to authorize and then are redirected to the catalog page if all is well. +For more information you can clone +[the backstage-auth-example repository](https://github.com/RoadieHQ/backstage-auth-example). +Each authentication setting is set up there on a branch named after the +authentication provider. + # Where to go from here > You're probably eager to write your first custom plugin. Follow this next diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 0f8d9bb84b..6208fbe30d 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -59,7 +59,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; ``` Simple! The App will reload with your changes automatically. You should now see -a github icon displayed in the sidebar. Clicking that will link to our new +a GitHub icon displayed in the sidebar. Clicking that will link to our new plugin. And now, the API fun begins. # The Identity @@ -72,8 +72,7 @@ Our first modification will be to extract information from the Identity API. ```tsx // Add identityApiRef to the list of imported from core -import { identityApiRef } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; +import { identityApiRef, useApi } from '@backstage/core'; ``` 3. Adjust the ExampleComponent from inline to block @@ -143,8 +142,8 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; const ExampleFetchComponent = () => { diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index f09c73fd83..98fbad57c5 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -6,7 +6,7 @@ authorURL: https://github.com/garyniemen Since we [open sourced Backstage](https://backstage.io/blog/2020/03/16/announcing-backstage), one of the most requested features has been for a technical documentation plugin. Well, good news. The first open source version of TechDocs is here. Now let’s start collaborating and making it better, together. - diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index c7ae9c492d..d265698694 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -6,7 +6,7 @@ authorURL: https://twitter.com/janisa_a How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources. - + Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage. diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md new file mode 100644 index 0000000000..a0d11a580f --- /dev/null +++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md @@ -0,0 +1,72 @@ +--- +title: New Backstage feature: Kubernetes for Service owners +author: Matthew Clarke, Spotify +authorURL: https://github.com/mclarke47 +--- + +![Animation of Kubernetes and cloud provider icons becoming the Backstage logo](assets/21-01-12/backstage-k8s-1-hero.gif) + +TLDR; We’re rethinking the Kubernetes developer experience with a new feature: a Kubernetes monitoring tool that’s designed around the needs of service owners, not cluster admins. Now developers can easily check the health of their services no matter how or where those services are deployed — whether it’s on a local host for testing or in production on dozens of clusters around the world. + +And since Backstage uses the native Kubernetes API, the feature works with whichever cloud provider (AWS, Azure, GCP, etc.) or managed service (OpenShift, IBM Cloud, GKE, etc.) you already use. + + + +## The missing link between K8s and your service + +A core feature of Backstage is its service catalog, which aggregates information about software systems together inside a single tool, with a consistent, familiar UI. + +By navigating to a service’s overview page in Backstage, you can see everything you need to know about the service: what it does, its APIs and technical documentation, CI/CD progress — and now detailed information about its presence on Kubernetes clusters. + +## No more context switching + +Kubernetes in Backstage can be configured to search multiple clusters for your services. It will then aggregate them together into a single view. So if you deploy to multiple clusters you will no longer need to switch kubectl contexts to understand the current state of your service. + +![List of deployments in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-2-deployments.png) + +## Automatic error reporting + +Instead of trying different kubectl commands to figure out where an error occurred, Backstage will automatically find and highlight errors in Kubernetes resources that are affecting your service. So you can spend time fixing errors, not hunting for them. + +![Error reporting screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-3-error-reporting.png) + +## Autoscaling limits at a glance + +Backstage also shows you how close your service is to its autoscaling limit. Coming up to a period of high load? Now you will be able to see how your horizontal autoscaling is dealing with it across multiple clusters. + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-4-autoscaling-limits.png) + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-5-autoscaling-limits.png) + +## Pick a cloud, any Cloud + +Since Backstage communicates directly with the Kubernetes API, it’s cloud agnostic — it doesn’t matter how or where you’re running Kubernetes. You’ll always get the same familiar view of your deployments, whether you’re: + +- Deploying to clusters on AWS, Azure, GCP, or another cloud provider +- Using an unmanaged or managed Kubernetes service (like OpenShift, etc.) +- Migrating from one cloud provider or service to another +- Testing on a single local machine or deploying to a dozen clusters in production + +In short: local or global, single or multi-cloud, managed or unmanaged — Backstage always provides a seamless Kubernetes experience for your service owners’ day-to-day development needs. + +## Rethinking the developer experience + +The philosophy behind Backstage is simple: improve developer experience by reducing infrastructure complexity. As popular and widespread as Kubernetes has become, all of the tools to date have been geared toward the needs of cluster admins. These tools add unnecessary complexity to the workflows of the typical developer building, testing, and deploying services. + +We believe Backstage Kubernetes gives developers back control of their services by providing a more focused and consistent experience. Backstage provides a single standard for developers to monitor their Kubernetes deployments, regardless of the underlying cloud infrastructure. + +## Future iterations + +The current focus of Kubernetes in Backstage is Deployments/ReplicaSets/Pods — but we know that not everyone utilizes these. + +As we continue to grow and develop Kubernetes in Backstage with the community, we hope to offer support for Kubernetes resources beyond Deployments and Custom Resource Definitions. You can browse or add open issues for the plugin [here]. + +## Getting started + +We made the Kubernetes plugin a core feature of Backstage. Like Software Templates (scaffolder) and TechDocs, the k8s-plugin is installed with the core app. When you update the app to the latest version and go to the Kubernetes tab of any service, you will be asked to provide your cloud provider credentials. To learn more, including details on configuration and surfacing your Kubernetes components as part of an entity, [read the docs]. + +To contribute or get more information on Kubernetes in Backstage, [join the discussion on Discord]! + +[here]: https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+kubernetes+label%3Ak8s-plugin +[read the docs]: https://backstage.io/docs/features/kubernetes/overview +[join the discussion on discord]: https://discord.gg/MUpMjP2 diff --git a/microsite/blog/assets/2020-12-22/stability-index-hero.gif b/microsite/blog/assets/2020-12-22/stability-index-hero.gif index 6744cf977d..6459d241e2 100644 Binary files a/microsite/blog/assets/2020-12-22/stability-index-hero.gif 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 index b6069b7d74..2a8d624687 100644 Binary files a/microsite/blog/assets/2020-12-22/versions-bump.png 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/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml index a47fa08d82..ca753f682a 100644 --- a/microsite/data/plugins/argo-cd.yaml +++ b/microsite/data/plugins/argo-cd.yaml @@ -2,7 +2,7 @@ title: Argo CD author: roadie.io authorUrl: https://roadie.io -category: CI +category: CI/CD description: View Argo CD status for your projects in Backstage. documentation: https://roadie.io/backstage/plugins/argo-cd iconUrl: https://roadie.io/images/logos/argo.png diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml index eeb9440aac..16c6960898 100644 --- a/microsite/data/plugins/buildkite.yaml +++ b/microsite/data/plugins/buildkite.yaml @@ -2,7 +2,7 @@ title: Buildkite author: roadie.io authorUrl: https://roadie.io -category: CI +category: CI/CD description: View Buildkite CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/buildkite iconUrl: https://roadie.io/images/logos/buildkite.png diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 467da9fb1e..0acad832fa 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -2,7 +2,7 @@ title: CircleCI author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png diff --git a/microsite/data/plugins/cloud-build.yaml b/microsite/data/plugins/cloud-build.yaml index 2b40d02de2..253a935392 100644 --- a/microsite/data/plugins/cloud-build.yaml +++ b/microsite/data/plugins/cloud-build.yaml @@ -2,7 +2,7 @@ title: Google Cloud Build author: Trivago authorUrl: https://www.trivago.com -category: CI +category: CI/CD description: Build, test, and deploy on Google's serverless CI/CD platform. documentation: https://github.com/backstage/backstage/tree/master/plugins/cloudbuild iconUrl: https://avatars2.githubusercontent.com/u/38220399?s=400&v=4 diff --git a/microsite/data/plugins/gcp-projects.yaml b/microsite/data/plugins/gcp-projects.yaml index 1c015821b2..7c4b75d2a4 100644 --- a/microsite/data/plugins/gcp-projects.yaml +++ b/microsite/data/plugins/gcp-projects.yaml @@ -2,7 +2,7 @@ title: GCP Project Creator author: Trivago authorUrl: https://www.trivago.com -category: Cloud +category: Infrastructure description: Create, list and manage your Google Cloud Projects. documentation: https://github.com/backstage/backstage/tree/master/plugins/gcp-projects iconUrl: https://avatars1.githubusercontent.com/u/2810941?s=280&v=4 diff --git a/microsite/data/plugins/github-actions.yaml b/microsite/data/plugins/github-actions.yaml index aeccdc9b86..8d5a74d9ce 100644 --- a/microsite/data/plugins/github-actions.yaml +++ b/microsite/data/plugins/github-actions.yaml @@ -2,7 +2,7 @@ title: GitHub Actions author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. documentation: https://github.com/backstage/backstage/tree/master/plugins/github-actions iconUrl: https://avatars2.githubusercontent.com/u/44036562?s=400&v=4 diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml index 1dba3dcfd1..f127f88a3c 100644 --- a/microsite/data/plugins/github-insights.yaml +++ b/microsite/data/plugins/github-insights.yaml @@ -2,7 +2,7 @@ title: GitHub Insights author: roadie.io authorUrl: https://roadie.io -category: Monitoring +category: Source Control Mgmt description: View GitHub Insights for your components in Backstage. documentation: https://roadie.io/backstage/plugins/github-insights iconUrl: https://roadie.io/images/logos/insights.png diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 8e44a4bf56..4172cdff6b 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -2,7 +2,7 @@ title: GitHub Pull Requests author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: Source Control Mgmt description: View GitHub pull requests for your service in Backstage. documentation: https://roadie.io/backstage/plugins/github-pull-requests iconUrl: https://roadie.io/images/logos/github.png diff --git a/microsite/data/plugins/jenkins.yaml b/microsite/data/plugins/jenkins.yaml index 638758d54d..7a8f884221 100644 --- a/microsite/data/plugins/jenkins.yaml +++ b/microsite/data/plugins/jenkins.yaml @@ -2,7 +2,7 @@ title: Jenkins author: '@timja' authorUrl: https://github.com/timja -category: CI +category: CI/CD description: Jenkins offers a simple way to set up a continuous integration and continuous delivery environment. documentation: https://github.com/backstage/backstage/tree/master/plugins/jenkins iconUrl: https://img.icons8.com/color/1600/jenkins.png diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml index 579243d11c..c32455c7d5 100644 --- a/microsite/data/plugins/jira.yaml +++ b/microsite/data/plugins/jira.yaml @@ -2,7 +2,7 @@ title: Jira author: roadie.io authorUrl: https://roadie.io -category: Project Management +category: Agile Planning description: View Jira summary for your projects in Backstage. documentation: https://roadie.io/backstage/plugins/jira iconUrl: https://roadie.io/images/logos/jira.png diff --git a/microsite/data/plugins/kafka.yaml b/microsite/data/plugins/kafka.yaml new file mode 100644 index 0000000000..47d354b44f --- /dev/null +++ b/microsite/data/plugins/kafka.yaml @@ -0,0 +1,11 @@ +--- +title: Kafka +author: '@nirga' +authorUrl: https://github.com/nirga +category: Monitoring +description: Observability for Apache Kafka clusters and async API of components. +documentation: https://github.com/backstage/backstage/tree/master/plugins/kafka +iconUrl: https://kafka.apache.org/images/apache-kafka.png +npmPackageName: '@backstage/plugin-kafka' +tags: + - monitoring diff --git a/microsite/data/plugins/lighthouse.yaml b/microsite/data/plugins/lighthouse.yaml index 88d444e0ef..be70dfd74a 100644 --- a/microsite/data/plugins/lighthouse.yaml +++ b/microsite/data/plugins/lighthouse.yaml @@ -2,7 +2,7 @@ title: Lighthouse author: Spotify authorUrl: https://github.com/spotify -category: Accessibility +category: Quality description: Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website. documentation: https://github.com/backstage/backstage/tree/master/plugins/lighthouse iconUrl: https://seeklogo.com/images/G/google-lighthouse-logo-1C7FA08580-seeklogo.com.png diff --git a/microsite/data/plugins/opsgenie.yaml b/microsite/data/plugins/opsgenie.yaml new file mode 100644 index 0000000000..43ba040f75 --- /dev/null +++ b/microsite/data/plugins/opsgenie.yaml @@ -0,0 +1,13 @@ +--- +title: Opsgenie +author: K-Phoen +authorUrl: https://github.com/K-Phoen +category: Monitoring +description: Opsgenie offers a simple way to associate alerts to components and vizualize incidents. +documentation: https://github.com/K-Phoen/backstage-plugin-opsgenie/ +iconUrl: https://avatars.githubusercontent.com/u/1818843?s=200&v=4 +npmPackageName: '@k-phoen/backstage-plugin-opsgenie' +tags: + - monitoring + - errors + - alerting diff --git a/microsite/data/plugins/snyk-security.yaml b/microsite/data/plugins/snyk-security.yaml new file mode 100644 index 0000000000..35f00595d1 --- /dev/null +++ b/microsite/data/plugins/snyk-security.yaml @@ -0,0 +1,9 @@ +--- +title: Snyk +author: Snyk Tech Services +authorUrl: https://snyk.io +category: Security +description: View Snyk scanned vulnerabilities and license compliance of your components directly in Backstage. +documentation: https://github.com/snyk-tech-services/backstage-plugin-snyk/blob/main/README.md +iconUrl: https://storage.googleapis.com/snyk-technical-services.appspot.com/snyk-logo-vertical-black.png +npmPackageName: 'backstage-plugin-snyk' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index fe1b18c9d4..a6b8c6ebd7 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -2,7 +2,7 @@ title: Travis CI author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: CI/CD description: View Travis CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/travis-ci iconUrl: https://roadie.io/images/logos/travis.png diff --git a/microsite/package.json b/microsite/package.json index e1860b8314..cb82e6ec85 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -15,8 +15,8 @@ }, "devDependencies": { "@spotify/prettier-config": "^9.0.0", - "docusaurus": "^2.0.0-alpha.378053ac5", - "js-yaml": "^3.14.1", + "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 c26fc9a8e8..47ea1ee9ee 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -100,9 +100,9 @@ const Background = props => { width="560" height="315" src="https://www.youtube.com/embed/YLAd5hdXR_Q" - frameborder="0" + frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen + allowFullScreen > @@ -135,9 +135,9 @@ const Background = props => { width="560" height="315" src="https://www.youtube.com/embed/mOLCgdPw1iA" - frameborder="0" + frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen + allowFullScreen > diff --git a/microsite/pages/en/docs/features/software-catalog/index.js b/microsite/pages/en/docs/features/software-catalog/index.js new file mode 100644 index 0000000000..cffc91af21 --- /dev/null +++ b/microsite/pages/en/docs/features/software-catalog/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/docs/features/software-templates/index.js b/microsite/pages/en/docs/features/software-templates/index.js new file mode 100644 index 0000000000..79d3f0659e --- /dev/null +++ b/microsite/pages/en/docs/features/software-templates/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/docs/features/techdocs/index.js b/microsite/pages/en/docs/features/techdocs/index.js new file mode 100644 index 0000000000..c45cde24f5 --- /dev/null +++ b/microsite/pages/en/docs/features/techdocs/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 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/sidebars.json b/microsite/sidebars.json index b31f0ccdf3..c2ccea8740 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -36,6 +36,11 @@ ], "CLI": ["cli/index", "cli/commands"], "Core Features": [ + { + "type": "subcategory", + "label": "Kubernetes", + "ids": ["features/kubernetes/overview"] + }, { "type": "subcategory", "label": "Software Catalog", @@ -50,6 +55,7 @@ "features/software-catalog/well-known-relations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", + "features/software-catalog/kubernetes-in-backstage", "features/software-catalog/software-catalog-api" ] }, @@ -66,6 +72,14 @@ "features/software-templates/extending/extending-preparer" ] }, + { + "type": "subcategory", + "label": "Backstage Search", + "ids": [ + "features/search/search-overview", + "features/search/architecture" + ] + }, { "type": "subcategory", "label": "TechDocs", @@ -77,6 +91,8 @@ "features/techdocs/creating-and-publishing", "features/techdocs/configuration", "features/techdocs/using-cloud-storage", + "features/techdocs/configuring-ci-cd", + "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" ] @@ -89,6 +105,7 @@ "plugins/plugin-development", "plugins/structure-of-a-plugin", "plugins/integrating-plugin-into-service-catalog", + "plugins/composability", { "type": "subcategory", "label": "Backends and APIs", @@ -166,10 +183,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" ], "Contribute": ["../CONTRIBUTING"], "Support": ["support/support", "support/project-structure"], + "Glossary": ["glossary"], "FAQ": ["FAQ"] } } diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 39ba05bcad..8d0c4ea57c 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -82,6 +82,7 @@ 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', ], // On page navigation for the current documentation page. diff --git a/microsite/static/animations/backstage-kubernetes-icon-1.gif b/microsite/static/animations/backstage-kubernetes-icon-1.gif new file mode 100644 index 0000000000..a7a653a3a3 Binary files /dev/null and b/microsite/static/animations/backstage-kubernetes-icon-1.gif differ diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 46b2aa3470..c947c88e66 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -48,6 +48,11 @@ h6 { color: $textColor; } +summary { + color: $textColor; + cursor: pointer; +} + h2:hover .hash-link { opacity: 1; } @@ -1030,6 +1035,55 @@ code { } } +.Banner { + position: relative; + padding: 14px; + margin: 14px 20px; + border-radius: 4px; + background-color: $primaryColor; + font-family: Helvetica Neue, sans-serif; + color: #000; +} + +.Banner--hidden { + opacity: 0; + transition: opacity 200ms ease-in-out; +} + +.Banner a { + color: #000; + text-decoration: underline; +} + +.Banner__Container { + position: relative; + overflow: visible; + z-index: 100; + + max-width: 1430px; + height: 0; + margin: -14px auto 14px auto; +} + +.Banner__DismissButton { + position: absolute; + display: flex; + right: 8px; + top: 0; + bottom: 0; + margin: auto; + + border-radius: 50%; + padding: 6px; + width: 36px; + height: 36px; + cursor: pointer; +} + +.Banner__DismissButton:hover { + background: rgba(0, 0, 0, 0.2); +} + .logos-mobile-background { position: absolute; width: 200vw; diff --git a/microsite/static/js/dismissable-banner.js b/microsite/static/js/dismissable-banner.js new file mode 100644 index 0000000000..2fcdbe5692 --- /dev/null +++ b/microsite/static/js/dismissable-banner.js @@ -0,0 +1,18 @@ +window.addEventListener('DOMContentLoaded', () => { + const banners = document.querySelectorAll('[data-banner]'); + banners.forEach(banner => { + const storageKey = `hideBanner/${banner.getAttribute('data-banner')}`; + + if (!localStorage.getItem(storageKey)) { + banner.classList.remove('Banner--hidden'); + } + + const dismissButton = banner.querySelector('[data-banner-dismiss]'); + if (dismissButton) { + dismissButton.addEventListener('click', () => { + banner.classList.add('Banner--hidden'); + localStorage.setItem(storageKey, 'true'); + }); + } + }); +}); diff --git a/microsite/yarn.lock b/microsite/yarn.lock index e41db2a665..898fd69ad2 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -9,55 +9,54 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/code-frame@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== "@babel/core@^7.12.3": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" - integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" + integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" + "@babel/generator" "^7.12.10" "@babel/helper-module-transforms" "^7.12.1" "@babel/helpers" "^7.12.5" - "@babel/parser" "^7.12.7" + "@babel/parser" "^7.12.10" "@babel/template" "^7.12.7" - "@babel/traverse" "^7.12.9" - "@babel/types" "^7.12.7" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" json5 "^2.1.2" lodash "^4.17.19" - resolve "^1.3.2" semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" - integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== +"@babel/generator@^7.12.10", "@babel/generator@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" + integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== dependencies: - "@babel/types" "^7.12.5" + "@babel/types" "^7.12.11" jsesc "^2.5.1" source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" - integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== +"@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" + integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" @@ -67,26 +66,9 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.12.4": - version "7.12.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz#55fc1ead5242caa0ca2875dcb8eed6d311e50f48" - integrity sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.12.1" - "@babel/types" "^7.12.1" - -"@babel/helper-builder-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" - integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-compilation-targets@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: "@babel/compat-data" "^7.12.5" @@ -96,7 +78,7 @@ "@babel/helper-create-class-features-plugin@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" + resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== dependencies: "@babel/helper-function-name" "^7.10.4" @@ -105,18 +87,9 @@ "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" -"@babel/helper-create-regexp-features-plugin@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" - integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - regexpu-core "^4.7.0" - "@babel/helper-create-regexp-features-plugin@^7.12.1": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -132,27 +105,27 @@ lodash "^4.17.19" "@babel/helper-explode-assignable-expression@^7.10.4": - version "7.11.4" - resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b" - integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ== + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.1.tgz#8006a466695c4ad86a2a5f2fb15b5f2c31ad5633" + integrity sha512-dmUwH8XmlrUpVqgtZ737tK88v07l840z9j3OEhCLwKTkjlvKpfqXVIZ0wpK3aeOxspwGrf/5AP5qLx4rO3w5rA== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" - integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== +"@babel/helper-function-name@^7.10.4", "@babel/helper-function-name@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" + integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-get-function-arity" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/types" "^7.12.11" -"@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== +"@babel/helper-get-function-arity@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" @@ -161,23 +134,23 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-member-expression-to-functions@^7.12.1": +"@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== dependencies: "@babel/types" "^7.12.7" "@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== dependencies: "@babel/types" "^7.12.5" "@babel/helper-module-transforms@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== dependencies: "@babel/helper-module-imports" "^7.12.1" @@ -190,28 +163,21 @@ "@babel/types" "^7.12.1" lodash "^4.17.19" -"@babel/helper-optimise-call-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" - integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== +"@babel/helper-optimise-call-expression@^7.10.4", "@babel/helper-optimise-call-expression@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-regex@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" - integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg== - dependencies: - lodash "^4.17.19" - "@babel/helper-remap-async-to-generator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -219,50 +185,50 @@ "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.12.1": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.5.tgz#f009a17543bbbbce16b06206ae73b63d3fca68d9" - integrity sha512-5YILoed0ZyIpF4gKcpZitEnXEJ9UoDRki1Ey6xz46rxOzfNMAhVIJMoune1hmPVxh40LRv1+oafz7UsWX+vyWA== + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" + integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== dependencies: - "@babel/helper-member-expression-to-functions" "^7.12.1" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.12.5" - "@babel/types" "^7.12.5" + "@babel/helper-member-expression-to-functions" "^7.12.7" + "@babel/helper-optimise-call-expression" "^7.12.10" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.11" "@babel/helper-simple-access@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== dependencies: "@babel/types" "^7.12.1" "@babel/helper-skip-transparent-expression-wrappers@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== dependencies: "@babel/types" "^7.12.1" -"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" - integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" + integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.11" -"@babel/helper-validator-identifier@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" - integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== -"@babel/helper-validator-option@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" - integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" + integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== "@babel/helper-wrap-function@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" - integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug== + version "7.12.3" + resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.12.3.tgz#3332339fc4d1fbbf1c27d7958c27d34708e990d9" + integrity sha512-Cvb8IuJDln3rs6tzjW3Y8UeelAOdnpB8xtQ4sme2MSZ9wOxrbThporC0y/EtE16VAtoyEfLM404Xr1e0OOp+ow== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/template" "^7.10.4" @@ -271,7 +237,7 @@ "@babel/helpers@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" @@ -287,20 +253,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.10.4": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" - integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== - -"@babel/parser@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" - integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg== +"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" + integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== "@babel/plugin-proposal-async-generator-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" - integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" + integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-remap-async-to-generator" "^7.12.1" @@ -308,7 +269,7 @@ "@babel/plugin-proposal-class-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" @@ -316,7 +277,7 @@ "@babel/plugin-proposal-dynamic-import@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -324,7 +285,7 @@ "@babel/plugin-proposal-export-namespace-from@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -332,7 +293,7 @@ "@babel/plugin-proposal-json-strings@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -340,7 +301,7 @@ "@babel/plugin-proposal-logical-assignment-operators@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -348,7 +309,7 @@ "@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -356,7 +317,7 @@ "@babel/plugin-proposal-numeric-separator@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -364,7 +325,7 @@ "@babel/plugin-proposal-object-rest-spread@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -373,7 +334,7 @@ "@babel/plugin-proposal-optional-catch-binding@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -381,7 +342,7 @@ "@babel/plugin-proposal-optional-chaining@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -390,28 +351,20 @@ "@babel/plugin-proposal-private-methods@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.12.1": +"@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" - integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-async-generators@^7.8.0": version "7.8.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -421,7 +374,7 @@ "@babel/plugin-syntax-class-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -449,7 +402,7 @@ "@babel/plugin-syntax-jsx@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -498,21 +451,21 @@ "@babel/plugin-syntax-top-level-await@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-arrow-functions@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-async-to-generator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== dependencies: "@babel/helper-module-imports" "^7.12.1" @@ -521,21 +474,21 @@ "@babel/plugin-transform-block-scoped-functions@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" - integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== +"@babel/plugin-transform-block-scoping@^7.12.11": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" + integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-classes@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -549,44 +502,36 @@ "@babel/plugin-transform-computed-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-destructuring@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.12.1": +"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" - integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-duplicate-keys@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-exponentiation-operator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== dependencies: "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" @@ -594,14 +539,14 @@ "@babel/plugin-transform-for-of@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-function-name@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== dependencies: "@babel/helper-function-name" "^7.10.4" @@ -609,21 +554,21 @@ "@babel/plugin-transform-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-member-expression-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" + resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-modules-amd@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -632,7 +577,7 @@ "@babel/plugin-transform-modules-commonjs@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -642,7 +587,7 @@ "@babel/plugin-transform-modules-systemjs@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== dependencies: "@babel/helper-hoist-variables" "^7.10.4" @@ -653,7 +598,7 @@ "@babel/plugin-transform-modules-umd@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -661,21 +606,21 @@ "@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/plugin-transform-new-target@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-object-super@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -683,61 +628,46 @@ "@babel/plugin-transform-parameters@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" + resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-property-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" + resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-display-name@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" integrity sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-jsx-development@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.7.tgz#4c2a647de79c7e2b16bfe4540677ba3121e82a08" - integrity sha512-Rs3ETtMtR3VLXFeYRChle5SsP/P9Jp/6dsewBQfokDSzKJThlsuFcnzLTDRALiUmTC48ej19YD9uN1mupEeEDg== + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.12.tgz#bccca33108fe99d95d7f9e82046bfe762e71f4e7" + integrity sha512-i1AxnKxHeMxUaWVXQOSIco4tvVvvCxMSfeBMnMM06mpaJt3g+MpxYQQrDfojUQldP1xxraPSJYSMEljoWM/dCg== dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.12.1" - -"@babel/plugin-transform-react-jsx-self@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz#ef43cbca2a14f1bd17807dbe4376ff89d714cf28" - integrity sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-react-jsx-source@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz#d07de6863f468da0809edcf79a1aa8ce2a82a26b" - integrity sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-react-jsx@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.7.tgz#8b14d45f6eccd41b7f924bcb65c021e9f0a06f7f" - integrity sha512-YFlTi6MEsclFAPIDNZYiCRbneg1MFGao9pPG9uD5htwE0vDbPaMUMeYd6itWjw7K4kro4UbdQf3ljmFl9y48dQ== - dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" + "@babel/plugin-transform-react-jsx" "^7.12.12" + +"@babel/plugin-transform-react-jsx@^7.12.10", "@babel/plugin-transform-react-jsx@^7.12.12": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.12.tgz#b0da51ffe5f34b9a900e9f1f5fb814f9e512d25e" + integrity sha512-JDWGuzGNWscYcq8oJVCtSE61a5+XAOos+V0HrxnDieUus4UMnBEosDnY1VJqU5iZ4pA04QY7l0+JvHL1hZEfsw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" + "@babel/types" "^7.12.12" "@babel/plugin-transform-react-pure-annotations@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -745,28 +675,28 @@ "@babel/plugin-transform-regenerator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== dependencies: regenerator-transform "^0.14.2" "@babel/plugin-transform-reserved-words@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" + resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-shorthand-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-spread@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -774,35 +704,35 @@ "@babel/plugin-transform-sticky-regex@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-template-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-typeof-symbol@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" - integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== +"@babel/plugin-transform-typeof-symbol@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" + integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-unicode-escapes@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-unicode-regex@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" @@ -810,22 +740,22 @@ "@babel/polyfill@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" + resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" integrity sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g== dependencies: core-js "^2.6.5" regenerator-runtime "^0.13.4" "@babel/preset-env@^7.12.1": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.7.tgz#54ea21dbe92caf6f10cb1a0a576adc4ebf094b55" - integrity sha512-OnNdfAr1FUQg7ksb7bmbKoby4qFOHw6DKWWUNB9KqnnCldxhxJlP+21dpyaWFmf2h0rTbOkXJtAGevY3XW1eew== + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" + integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== dependencies: "@babel/compat-data" "^7.12.7" "@babel/helper-compilation-targets" "^7.12.5" "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-validator-option" "^7.12.1" + "@babel/helper-validator-option" "^7.12.11" "@babel/plugin-proposal-async-generator-functions" "^7.12.1" "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-dynamic-import" "^7.12.1" @@ -854,7 +784,7 @@ "@babel/plugin-transform-arrow-functions" "^7.12.1" "@babel/plugin-transform-async-to-generator" "^7.12.1" "@babel/plugin-transform-block-scoped-functions" "^7.12.1" - "@babel/plugin-transform-block-scoping" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.11" "@babel/plugin-transform-classes" "^7.12.1" "@babel/plugin-transform-computed-properties" "^7.12.1" "@babel/plugin-transform-destructuring" "^7.12.1" @@ -880,12 +810,12 @@ "@babel/plugin-transform-spread" "^7.12.1" "@babel/plugin-transform-sticky-regex" "^7.12.7" "@babel/plugin-transform-template-literals" "^7.12.1" - "@babel/plugin-transform-typeof-symbol" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.10" "@babel/plugin-transform-unicode-escapes" "^7.12.1" "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.12.7" - core-js-compat "^3.7.0" + "@babel/types" "^7.12.11" + core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": @@ -900,22 +830,20 @@ esutils "^2.0.2" "@babel/preset-react@^7.12.5": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.12.7.tgz#36d61d83223b07b6ac4ec55cf016abb0f70be83b" - integrity sha512-wKeTdnGUP5AEYCYQIMeXMMwU7j+2opxrG0WzuZfxuuW9nhKvvALBjl67653CWamZJVefuJGI219G591RSldrqQ== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.10.tgz#4fed65f296cbb0f5fb09de6be8cddc85cc909be9" + integrity sha512-vtQNjaHRl4DUpp+t+g4wvTHsLQuye+n0H/wsXIZRn69oz/fvNC7gQ4IK73zGJBaxvHoxElDvnYCthMcT7uzFoQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-display-name" "^7.12.1" - "@babel/plugin-transform-react-jsx" "^7.12.7" + "@babel/plugin-transform-react-jsx" "^7.12.10" "@babel/plugin-transform-react-jsx-development" "^7.12.7" - "@babel/plugin-transform-react-jsx-self" "^7.12.1" - "@babel/plugin-transform-react-jsx-source" "^7.12.1" "@babel/plugin-transform-react-pure-annotations" "^7.12.1" "@babel/register@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.12.1.tgz#cdb087bdfc4f7241c03231f22e15d211acf21438" - integrity sha512-XWcmseMIncOjoydKZnWvWi0/5CUCD+ZYKhRwgYlWOrA8fGZ/FjuLRpqtIhLOVD/fvR1b9DQHtZPn68VvhpYf+Q== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/register/-/register-7.12.10.tgz#19b87143f17128af4dbe7af54c735663b3999f60" + integrity sha512-EvX/BvMMJRAA3jZgILWgbsrHwBQvllC5T8B29McyME8DvkdOxk4ujESfrMvME8IHSDvWXrmMXxPvA/lx2gqPLQ== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -924,51 +852,42 @@ source-map-support "^0.5.16" "@babel/runtime@^7.8.4": - version "7.11.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" - integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/parser" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/template@^7.12.7": +"@babel/template@^7.10.4", "@babel/template@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== dependencies: "@babel/code-frame" "^7.10.4" "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" - integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== +"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" + integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.7" - "@babel/types" "^7.12.7" + "@babel/code-frame" "^7.12.11" + "@babel/generator" "^7.12.11" + "@babel/helper-function-name" "^7.12.11" + "@babel/helper-split-export-declaration" "^7.12.11" + "@babel/parser" "^7.12.11" + "@babel/types" "^7.12.12" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.4.4": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" - integrity sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.4.4": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" + integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== dependencies: - "@babel/helper-validator-identifier" "^7.10.4" + "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" @@ -996,21 +915,16 @@ integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== "@types/cheerio@^0.22.8": - version "0.22.21" - resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.21.tgz#5e37887de309ba11b2e19a6e14cad7874b31a8a3" - integrity sha512-aGI3DfswwqgKPiEOTaiHV2ZPC9KEhprpgEbJnv0fZl3SGX0cGgEva1126dGrMC6AJM6v/aihlUgJn9M5DbDZ/Q== + version "0.22.23" + resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.23.tgz#74bcfee9c5ee53f619711dca953a89fe5cfa4eb4" + integrity sha512-QfHLujVMlGqcS/ePSf3Oe5hK3H8wi/yN2JYuxSB1U10VvW1fO3K8C+mURQesFYS1Hn7lspOsTT75SKq/XtydQg== dependencies: "@types/node" "*" -"@types/color-name@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" - integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== - "@types/node@*": - version "14.6.4" - resolved "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz#a145cc0bb14ef9c4777361b7bbafa5cf8e3acb5a" - integrity sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ== + version "14.14.20" + resolved "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340" + integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A== "@types/q@^1.5.1": version "1.5.4" @@ -1031,9 +945,9 @@ address@1.1.2, address@^1.0.1: integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== ajv@^6.12.3: - version "6.12.4" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1085,11 +999,10 @@ ansi-styles@^3.2.1: color-convert "^1.9.0" ansi-styles@^4.1.0: - version "4.2.1" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" - integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - "@types/color-name" "^1.1.1" color-convert "^2.0.1" ansi-wrap@0.1.0: @@ -1106,9 +1019,9 @@ anymatch@^2.0.0: normalize-path "^2.1.1" arch@^2.1.0: - version "2.1.2" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" - integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== archive-type@^4.0.0: version "4.0.0" @@ -1124,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" @@ -1207,7 +1125,7 @@ asynckit@^0.4.0: at-least-node@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== atob@^2.1.2: @@ -1216,9 +1134,9 @@ atob@^2.1.2: integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== autolinker@^3.11.0: - version "3.14.1" - resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.1.tgz#6ae4b812b6eaf42d4d68138b9e67757cbf2bc1e4" - integrity sha512-yvsRHIaY51EYDml6MGlbqyJGfl4n7zezGYf+R7gvM8c5LNpRGc4SISkvgAswSS8SWxk/OrGCylKV9mJyVstz7w== + version "3.14.2" + resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.2.tgz#71856274eb768fb7149039e24d3a2be2f5c55a63" + integrity sha512-VO66nXUCZFxTq7fVHAaiAkZNXRQ1l3IFi6D5P7DLoyIEAn2E8g7TWbyEgLlz1uW74LfWmu1A17IPWuPQyGuNVg== dependencies: tslib "^1.9.3" @@ -1248,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" @@ -1278,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" @@ -1440,24 +1358,14 @@ browserslist@4.7.0: electron-to-chromium "^1.3.247" node-releases "^1.1.29" -browserslist@^4.0.0, browserslist@^4.12.0: - version "4.14.1" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3" - integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA== +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0: + version "4.16.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" + integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== dependencies: - caniuse-lite "^1.0.30001124" - electron-to-chromium "^1.3.562" - escalade "^3.0.2" - node-releases "^1.1.60" - -browserslist@^4.14.5, browserslist@^4.14.7: - version "4.15.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.15.0.tgz#3d48bbca6a3f378e86102ffd017d9a03f122bdb0" - integrity sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ== - dependencies: - caniuse-lite "^1.0.30001164" + caniuse-lite "^1.0.30001165" colorette "^1.2.1" - electron-to-chromium "^1.3.612" + electron-to-chromium "^1.3.621" escalade "^3.1.1" node-releases "^1.1.67" @@ -1490,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" @@ -1535,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" @@ -1582,15 +1498,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124: - version "1.0.30001124" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2" - integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA== - -caniuse-lite@^1.0.30001164: - version "1.0.30001164" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001164.tgz#5bbfd64ca605d43132f13cc7fdabb17c3036bfdc" - integrity sha512-G+A/tkf4bu0dSp9+duNiXc7bGds35DioCyC6vgK2m/rjA4Krpy5WeZgZyfH2f0wj2kI6yAWWucyap6oOwmY1mg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: + version "1.0.30001173" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz#3c47bbe3cd6d7a9eda7f50ac016d158005569f56" + integrity sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw== caseless@~0.12.0: version "0.12.0" @@ -1770,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" @@ -1897,18 +1808,18 @@ copy-descriptor@^0.1.0: resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.7.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.0.tgz#3248c6826f4006793bd637db608bca6e4cd688b1" - integrity sha512-o9QKelQSxQMYWHXc/Gc4L8bx/4F7TTraE5rhuN8I7mKBt5dBIUpXpIR3omv70ebr8ST5R3PqbDQr+ZI3+Tt1FQ== +core-js-compat@^3.8.0: + version "3.8.2" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.2.tgz#3717f51f6c3d2ebba8cbf27619b57160029d1d4c" + integrity sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ== dependencies: - browserslist "^4.14.7" + browserslist "^4.16.0" semver "7.0.0" core-js@^2.6.5: - version "2.6.11" - resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + version "2.6.12" + resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -2000,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: @@ -2014,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" @@ -2092,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" @@ -2119,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" @@ -2215,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== @@ -2285,9 +2189,9 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.378053ac5: +docusaurus@^2.0.0-alpha.70: version "2.0.0-alpha.378053ac5" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" + resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" integrity sha512-+NM1NrJKYcmHYiMQ/4b5ew9QDbLW3QI/ii2lCs/gpEZhtU0FVnl1RLC3FUO41xgOhth3A8M5G5ChzlPuNzxjug== dependencies: "@babel/core" "^7.12.3" @@ -2359,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" @@ -2387,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" @@ -2451,15 +2355,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.562: - version "1.3.562" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f" - integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg== - -electron-to-chromium@^1.3.612: - version "1.3.614" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.614.tgz#ff359e8d2249e2ce859a4c2bc34c22bd2e2eb0a2" - integrity sha512-JMDl46mg4G+n6q/hAJkwy9eMTj5FJjsE+8f/irAGRMLM4yeRVbMuRrdZrbbGGOrGVcZc4vJPjUpEUWNb/fA6hg== +electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.621: + version "1.3.634" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz#82ea400f520f739c4f6ff00c1f7524827a917d25" + integrity sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw== "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" @@ -2489,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" @@ -2507,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" @@ -2533,14 +2450,9 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -escalade@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" - integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== - escalade@^3.1.1: version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-html@~1.0.3: @@ -2793,7 +2705,7 @@ fd-slicer@~1.1.0: feed@^4.2.1: version "4.2.1" - resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" + resolved "https://registry.npmjs.org/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" integrity sha512-l28KKcK1J/u3iq5dRDmmoB2p7dtBfACC2NqJh4dI2kFptxH0asfjmOfcxqh5Sv8suAlVa73gZJ4REY5RrafVvg== dependencies: xml-js "^1.6.11" @@ -3004,7 +2916,7 @@ fs-constants@^1.0.0: fs-extra@^9.0.1: version "9.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== dependencies: at-least-node "^1.0.0" @@ -3038,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" @@ -3098,7 +3019,7 @@ gifsicle@^4.0.0: github-slugger@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" + resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== dependencies: emoji-regex ">=6.0.0 <=6.1.1" @@ -3289,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== @@ -3345,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" @@ -3409,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" @@ -3429,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" @@ -3535,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" @@ -3571,10 +3492,10 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^4.1.0: + version "4.2.0" + resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz#a03f5eb661d9a154e3973a03de8b23dd0ad6892e" + integrity sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A== ipaddr.js@1.9.1: version "1.9.1" @@ -3622,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" @@ -3639,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" @@ -3739,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" @@ -3764,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" @@ -3785,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== @@ -3838,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== @@ -3858,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" @@ -3921,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.1, js-yaml@^3.8.1: +js-yaml@^3.13.1, js-yaml@^3.8.1: version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" 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" @@ -3990,7 +3930,7 @@ json5@^2.1.2: jsonfile@^6.0.1: version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== dependencies: universalify "^2.0.0" @@ -4322,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" @@ -4392,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" @@ -4456,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" @@ -4503,15 +4448,10 @@ node-modules-regexp@^1.0.0: resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= -node-releases@^1.1.29, node-releases@^1.1.60: - version "1.1.60" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" - integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== - -node-releases@^1.1.67: - version "1.1.67" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" - integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== +node-releases@^1.1.29, node-releases@^1.1.67: + version "1.1.69" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz#3149dbde53b781610cd8b486d62d86e26c3725f6" + integrity sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA== normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.5.0" @@ -4600,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== @@ -4617,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" @@ -4643,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: @@ -4949,7 +4890,7 @@ pkg-up@2.0.0: portfinder@^1.0.28: version "1.0.28" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== dependencies: async "^2.6.2" @@ -4962,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" @@ -5199,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" @@ -5237,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" @@ -5261,9 +5203,9 @@ prettier@^2.2.1: integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== prismjs@^1.22.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa" - integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w== + version "1.23.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== optionalDependencies: clipboard "^2.0.0" @@ -5415,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" @@ -5425,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" @@ -5435,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" @@ -5521,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" @@ -5545,21 +5487,9 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexpu-core@^4.7.0: - version "4.7.0" - resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938" - integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ== - dependencies: - regenerate "^1.4.0" - regenerate-unicode-properties "^8.2.0" - regjsgen "^0.5.1" - regjsparser "^0.6.4" - unicode-match-property-ecmascript "^1.0.4" - unicode-match-property-value-ecmascript "^1.2.0" - regexpu-core@^4.7.1: version "4.7.1" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== dependencies: regenerate "^1.4.0" @@ -5665,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: @@ -5715,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" @@ -6018,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" @@ -6095,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" @@ -6261,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" @@ -6421,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" @@ -6503,12 +6434,12 @@ uniqs@^2.0.0: universalify@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== universalify@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== unpipe@1.0.0, unpipe@~1.0.0: @@ -6578,7 +6509,7 @@ use@^3.1.0: resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= diff --git a/mkdocs.yml b/mkdocs.yml index 109be46a58..a2cbf11b4c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,9 @@ nav: - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Backstage Search: + - Overview: 'features/search/README.md' + - Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' @@ -56,14 +59,18 @@ nav: - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - Configuration: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' + - Kubernetes: + - Overview: 'features/kubernetes/index.md' - Plugins: - Overview: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - Creating a new plugin: 'plugins/create-a-plugin.md' - Developing a plugin: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - Backstage backend plugin: 'plugins/backend-plugin.md' @@ -111,8 +118,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' + - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' - Contribute: '../CONTRIBUTING.md' - Support: - 'support/support.md' - 'support/project-structure.md' + - Glossary: glossary.md - FAQ: FAQ.md diff --git a/package.json b/package.json index 8376915729..758a8ba884 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docgen": "lerna run docgen", - "docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage", "docker-build": "yarn tsc && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", @@ -43,7 +42,7 @@ "version": "1.0.0", "devDependencies": { "@changesets/cli": "^2.11.0", - "@octokit/openapi-types": "^2.0.0", + "@octokit/openapi-types": "^2.2.0", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^9.0.0", "command-exists": "^1.2.9", @@ -76,7 +75,7 @@ }, "jest": { "transformModules": [ - "@kyma-project/asyncapi-react" + "@asyncapi/react-component" ] } } diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 3eaf5ad111..d6157f560f 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,83 @@ # example-app +## 0.2.12 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [46bba09ea] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [8c2437c15] +- Updated dependencies [2b514d532] +- Updated dependencies [33846acfc] +- Updated dependencies [b604a9d41] +- Updated dependencies [d014185db] +- Updated dependencies [a187b8ad0] +- Updated dependencies [8855f61f6] +- Updated dependencies [ed6baab66] +- Updated dependencies [f04db53d7] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [debf359b5] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + - @backstage/plugin-cost-insights@0.5.7 + - @backstage/plugin-catalog-import@0.3.5 + - @backstage/cli@0.4.7 + - @backstage/plugin-kubernetes@0.3.6 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-cloudbuild@0.2.7 + - @backstage/plugin-jenkins@0.3.6 + - @backstage/plugin-kafka@0.1.1 + - @backstage/plugin-org@0.3.4 + - @backstage/plugin-pagerduty@0.2.6 + - @backstage/plugin-register-component@0.2.7 + - @backstage/plugin-rollbar@0.2.8 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-sentry@0.3.3 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-gcp-projects@0.2.3 + - @backstage/plugin-gitops-profiles@0.2.3 + - @backstage/plugin-graphiql@0.2.6 + - @backstage/plugin-newrelic@0.2.3 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-user-settings@0.2.4 + - @backstage/plugin-welcome@0.2.4 + +## 0.2.9 + +### Patch Changes + +- Updated dependencies [ab0892358] +- Updated dependencies [37a7d26c4] +- Updated dependencies [8e083f41f] +- Updated dependencies [88da267cc] +- Updated dependencies [9c09a364f] +- Updated dependencies [01707438b] +- Updated dependencies [edb7d0775] +- Updated dependencies [818d45e94] +- Updated dependencies [0588be01f] +- Updated dependencies [b8abdda57] +- Updated dependencies [b7a124883] +- Updated dependencies [bc909178d] +- Updated dependencies [947d3c269] + - @backstage/plugin-cost-insights@0.5.5 + - @backstage/plugin-tech-radar@0.3.2 + - @backstage/cli@0.4.5 + - @backstage/plugin-scaffolder@0.3.6 + - @backstage/plugin-sentry@0.3.2 + - @backstage/plugin-catalog@0.2.10 + - @backstage/plugin-search@0.2.5 + - @backstage/plugin-catalog-import@0.3.3 + - @backstage/plugin-pagerduty@0.2.5 + ## 0.2.8 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 7541dd59a3..7734dfc244 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,43 +1,43 @@ { "name": "example-app", - "version": "0.2.8", + "version": "0.2.12", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.3", - "@backstage/core": "^0.4.2", - "@backstage/plugin-api-docs": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.8", - "@backstage/plugin-catalog-import": "^0.3.2", - "@backstage/plugin-circleci": "^0.2.5", - "@backstage/plugin-cloudbuild": "^0.2.5", - "@backstage/plugin-cost-insights": "^0.5.2", - "@backstage/plugin-explore": "^0.2.2", - "@backstage/plugin-gcp-projects": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.6", - "@backstage/plugin-gitops-profiles": "^0.2.2", - "@backstage/plugin-graphiql": "^0.2.3", - "@backstage/plugin-org": "^0.3.2", - "@backstage/plugin-jenkins": "^0.3.4", - "@backstage/plugin-kubernetes": "^0.3.3", - "@backstage/plugin-lighthouse": "^0.2.6", - "@backstage/plugin-newrelic": "^0.2.2", - "@backstage/plugin-pagerduty": "0.2.4", - "@backstage/plugin-register-component": "^0.2.5", - "@backstage/plugin-rollbar": "^0.2.7", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/plugin-sentry": "^0.3.1", - "@backstage/plugin-search": "^0.2.4", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.1", - "@backstage/plugin-user-settings": "^0.2.3", - "@backstage/plugin-welcome": "^0.2.3", - "@backstage/test-utils": "^0.1.6", + "@backstage/catalog-model": "^0.7.0", + "@backstage/cli": "^0.4.7", + "@backstage/core": "^0.5.0", + "@backstage/plugin-api-docs": "^0.4.3", + "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-import": "^0.3.5", + "@backstage/plugin-circleci": "^0.2.6", + "@backstage/plugin-cloudbuild": "^0.2.7", + "@backstage/plugin-cost-insights": "^0.5.7", + "@backstage/plugin-explore": "^0.2.3", + "@backstage/plugin-gcp-projects": "^0.2.3", + "@backstage/plugin-github-actions": "^0.3.0", + "@backstage/plugin-gitops-profiles": "^0.2.3", + "@backstage/plugin-graphiql": "^0.2.6", + "@backstage/plugin-org": "^0.3.4", + "@backstage/plugin-jenkins": "^0.3.6", + "@backstage/plugin-kafka": "^0.1.1", + "@backstage/plugin-kubernetes": "^0.3.6", + "@backstage/plugin-lighthouse": "^0.2.8", + "@backstage/plugin-newrelic": "^0.2.3", + "@backstage/plugin-pagerduty": "0.2.6", + "@backstage/plugin-register-component": "^0.2.7", + "@backstage/plugin-rollbar": "^0.2.8", + "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-sentry": "^0.3.3", + "@backstage/plugin-search": "^0.2.6", + "@backstage/plugin-tech-radar": "^0.3.3", + "@backstage/plugin-techdocs": "^0.5.4", + "@backstage/plugin-user-settings": "^0.2.4", + "@backstage/plugin-welcome": "^0.2.4", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", - "@octokit/rest": "^18.0.0", + "@octokit/rest": "^18.0.12", "@roadiehq/backstage-plugin-buildkite": "^0.1.3", "@roadiehq/backstage-plugin-github-insights": "^0.2.16", "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3", @@ -53,6 +53,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/test-utils": "^0.1.6", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a58bf45f9c..1b76773979 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,6 +20,7 @@ import { OAuthRequestDialog, SignInPage, createRouteRef, + FlatRoutes, } from '@backstage/core'; import React from 'react'; import Root from './components/Root'; @@ -29,13 +30,13 @@ 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 { GraphiQLPage } from '@backstage/plugin-graphiql'; +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 { Route, Navigate } from 'react-router'; import { EntityPage } from './components/catalog/EntityPage'; @@ -65,31 +66,31 @@ const catalogRouteRef = createRouteRef({ title: 'Service Catalog', }); -const AppRoutes = () => ( - +const routes = ( + } /> } /> - } /> + } /> } /> - } /> - } /> + } /> + } /> } /> } /> {...deprecatedAppRoutes} - + ); const App = () => ( @@ -97,9 +98,7 @@ const App = () => ( - - - + {routes} ); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index a4d097b1d4..a1ab181211 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -63,6 +63,7 @@ import { UserProfileCard, } from '@backstage/plugin-org'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; +import { Router as KafkaRouter } from '@backstage/plugin-kafka'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Button, Grid } from '@material-ui/core'; import { @@ -243,6 +244,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( title="Code Insights" element={} /> + } + /> ); diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 6a4924cb0e..f07be07ec1 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -43,3 +43,4 @@ 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 0e0a3074a4..84265c9897 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,99 @@ # @backstage/backend-common +## 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 diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index b7241bcc03..74c199e737 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -41,31 +41,16 @@ export interface Config { https?: | true | { - /** - * Certificate configuration or parameters for generating a self-signed certificate - * - * Setting parameters for self-signed certificates is deprecated and will be removed in - * the future, set `backend.https = true` instead. - */ - certificate?: - | { - /** Algorithm to use to generate a self-signed certificate */ - algorithm?: string; - keySize?: number; - days?: number; - attributes: { - commonName: string; - }; - } - | { - /** PEM encoded certificate. Use $file to load in a file */ - cert: string; - /** - * PEM encoded certificate key. Use $file to load in a file. - * @visibility secret - */ - key: string; - }; + /** Certificate configuration */ + certificate?: { + /** PEM encoded certificate. Use $file to load in a file */ + cert: string; + /** + * PEM encoded certificate key. Use $file to load in a file. + * @visibility secret + */ + key: string; + }; }; /** Database connection configuration, select database type using the `client` field */ @@ -94,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. * @@ -104,81 +109,4 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; }; - - /** Configuration for integrations towards various external repository provider systems */ - integrations?: { - /** Integration configuration for Azure */ - azure?: Array<{ - /** - * The hostname of the given Azure instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - - /** Integration configuration for BitBucket */ - bitbucket?: Array<{ - /** - * The hostname of the given Bitbucket instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 - */ - apiBaseUrl?: string; - /** - * The username to use for authenticated requests. - * @visibility secret - */ - username?: string; - /** - * BitBucket app password used to authenticate requests. - * @visibility secret - */ - appPassword?: string; - }>; - - /** Integration configuration for GitHub */ - github?: Array<{ - /** - * The hostname of the given GitHub instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the GitHub API, for example https://api.github.com - */ - apiBaseUrl?: string; - /** - * The base url for GitHub raw resources, for example https://raw.githubusercontent.com - */ - rawBaseUrl?: string; - }>; - - /** Integration configuration for GitLab */ - gitlab?: Array<{ - /** - * The hostname of the given GitLab instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - }; } diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index a6b47bd7b4..c698351615 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.4.1", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,8 +31,8 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.0", - "@backstage/integration": "^0.1.4", + "@backstage/config-loader": "^0.4.1", + "@backstage/integration": "^0.3.0", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -66,9 +66,9 @@ } }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.7", "@backstage/test-utils": "^0.1.5", - "@types/archiver": "^3.1.1", + "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", "@types/fs-extra": "^9.0.3", @@ -82,7 +82,6 @@ "@types/tar": "^4.0.3", "@types/unzipper": "^0.10.3", "@types/webpack-env": "^1.15.2", - "@types/yaml": "^1.9.7", "get-port": "^5.1.1", "http-errors": "^1.7.3", "jest": "^26.0.1", diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 86beb65805..6989f3567b 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -37,7 +37,6 @@ export async function loadBackendConfig(options: Options): Promise { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); const configs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', configRoot: paths.targetRoot, configPaths: configOpts.map(opt => resolvePath(opt)), }); diff --git a/packages/backend-common/src/errors.ts b/packages/backend-common/src/errors.ts index b68dc8e3f0..39703127e6 100644 --- a/packages/backend-common/src/errors.ts +++ b/packages/backend-common/src/errors.ts @@ -75,3 +75,8 @@ export class NotFoundError extends CustomErrorBase {} * resource. */ export class ConflictError extends CustomErrorBase {} + +/** + * The requested resource has not changed since last request. + */ +export class NotModifiedError extends CustomErrorBase {} diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index a7a3d64bd1..6d22c2f175 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -72,6 +72,9 @@ describe('errorHandler', () => { it('handles well-known error classes', async () => { const app = express(); + app.use('/NotModifiedError', () => { + throw new errors.NotModifiedError(); + }); app.use('/InputError', () => { throw new errors.InputError(); }); @@ -90,6 +93,7 @@ describe('errorHandler', () => { app.use(errorHandler()); const r = request(app); + expect((await r.get('/NotModifiedError')).status).toBe(304); expect((await r.get('/InputError')).status).toBe(400); expect((await r.get('/AuthenticationError')).status).toBe(401); expect((await r.get('/NotAllowedError')).status).toBe(403); diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 7365ce8b93..a08849813d 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -101,6 +101,8 @@ function getStatusCode(error: Error): number { // Handle well-known error types switch (error.name) { + case errors.NotModifiedError.name: + return 304; case errors.InputError.name: return 400; case errors.AuthenticationError.name: diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index 616cbaaadc..20f8feba42 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -23,6 +23,7 @@ import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError } from '../errors'; const logger = getVoidLogger(); @@ -139,7 +140,12 @@ 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( + { host: 'dev.azure.com' }, + { treeResponseFactory }, ); beforeEach(() => { @@ -153,24 +159,70 @@ 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('throws a NotModifiedError when given a etag in options', async () => { + const fnAzure = async () => { + await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + { etag: '123abc2' }, + ); + }; + + await expect(fnAzure).rejects.toThrow(NotModifiedError); + }); + + it('should not throw a NotModifiedError when given an outdated etag in options', async () => { + const response = await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + { etag: 'outdated123abc' }, + ); + + expect(response.etag).toBe('123abc2'); + const files = await response.files(); + + expect(files.length).toBe(2); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ea716a1d4a..578db2ac92 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -20,10 +20,11 @@ import { getAzureFileFetchUrl, getAzureDownloadUrl, getAzureRequestOptions, + getAzureCommitsUrl, } from '@backstage/integration'; import fetch from 'cross-fetch'; import { Readable } from 'stream'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReaderFactory, ReadTreeOptions, @@ -75,20 +76,42 @@ export class AzureUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const response = await fetch( - getAzureDownloadUrl(url), - getAzureRequestOptions(this.options, { Accept: 'application/zip' }), + // TODO: Support filepath based reading tree feature like other providers + + // Get latest commit SHA + + const commitsAzureResponse = await fetch( + getAzureCommitsUrl(url), + getAzureRequestOptions(this.options), ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!commitsAzureResponse.ok) { + const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`; + if (commitsAzureResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - return this.deps.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, + const commitSha = (await commitsAzureResponse.json()).value[0].commitId; + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + const archiveAzureResponse = await fetch( + getAzureDownloadUrl(url), + getAzureRequestOptions(this.options, { Accept: 'application/zip' }), + ); + if (!archiveAzureResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; + if (archiveAzureResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return await this.deps.treeResponseFactory.fromZipArchive({ + stream: (archiveAzureResponse.body as unknown) as Readable, + etag: commitSha, filter: options?.filter, }); } diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index e327770114..9661368b5e 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; +import { NotModifiedError } from '../errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { ReadTreeResponseFactory } from './tree'; @@ -27,15 +28,24 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const bitbucketProcessor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, + { treeResponseFactory }, +); + +const hostedBitbucketProcessor = new BitbucketUrlReader( + { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }, + { treeResponseFactory }, +); + describe('BitbucketUrlReader', () => { describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); await expect( - processor.read('https://not.bitbucket.com/apa'), + bitbucketProcessor.read('https://not.bitbucket.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path', ); @@ -55,14 +65,40 @@ describe('BitbucketUrlReader', () => { ), ); - it('returns the wanted files from an archive', async () => { + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); + + beforeEach(() => { worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), rest.get( 'https://bitbucket.org/backstage/mock/get/master.zip', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + ), ctx.body(repoBuffer), ), ), @@ -76,17 +112,39 @@ describe('BitbucketUrlReader', () => { }), ), ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.zip', + ), + ctx.body(privateBitbucketRepoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), ); + }); - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + 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); @@ -98,38 +156,12 @@ describe('BitbucketUrlReader', () => { }); it('uses private bitbucket host', async () => { - const privateBitbucketRepoBuffer = fs.readFileSync( - path.resolve( - 'src', - 'reading', - '__fixtures__', - 'bitbucket-server-repo.zip', - ), - ); - worker.use( - rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/zip'), - ctx.body(privateBitbucketRepoBuffer), - ), - ), - ); - - const processor = new BitbucketUrlReader( - { - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', - }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + const response = await hostedBitbucketProcessor.readTree( 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', ); + expect(response.etag).toBe('12ab34cd56ef'); + const files = await response.files(); expect(files.length).toBe(1); @@ -139,37 +171,12 @@ describe('BitbucketUrlReader', () => { }); it('returns the wanted files from an archive with a subpath', async () => { - worker.use( - rest.get( - 'https://bitbucket.org/backstage/mock/get/master.zip', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), - ), - ), - rest.get( - 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', - (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], - }), - ), - ), - ); - - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + const response = await bitbucketProcessor.readTree( 'https://bitbucket.org/backstage/mock/src/master/docs', ); + expect(response.etag).toBe('12ab34cd56ef'); + const files = await response.files(); expect(files.length).toBe(1); @@ -177,5 +184,25 @@ describe('BitbucketUrlReader', () => { expect(indexMarkdownFile.toString()).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'); + }); }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 4367424423..e9727e04cf 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -23,9 +23,9 @@ import { readBitbucketIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { Readable } from 'stream'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, @@ -101,33 +101,52 @@ export class BitbucketUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const gitUrl: parseGitUri.GitUrl = parseGitUri(url); - const { name: repoName, owner: project, resource, filepath } = gitUrl; + const { filepath } = parseGitUrl(url); - const isHosted = resource === 'bitbucket.org'; + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } const downloadUrl = await getBitbucketDownloadUrl(url, this.config); - const response = await fetch( + const archiveBitbucketResponse = await fetch( downloadUrl, getBitbucketRequestOptions(this.config), ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!archiveBitbucketResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`; + if (archiveBitbucketResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - let folderPath = `${project}-${repoName}`; - if (isHosted) { - const lastCommitShortHash = await this.getLastCommitShortHash(url); - folderPath = `${project}-${repoName}-${lastCommitShortHash}`; + // Get the filename of archive from the header of the response + const contentDispositionHeader = archiveBitbucketResponse.headers.get( + 'content-disposition', + ) as string; + if (!contentDispositionHeader) { + throw new Error( + `Failed to read tree from ${url}. ` + + 'Bitbucket API response for downloading archive does not contain content-disposition header ', + ); + } + const fileNameRegEx = new RegExp( + /^attachment; filename=(?.*).zip$/, + ); + const archiveFileName = contentDispositionHeader.match(fileNameRegEx) + ?.groups?.fileName; + if (!archiveFileName) { + throw new Error( + `Failed to read tree from ${url}. Bitbucket API response for downloading archive has an unexpected ` + + `format of content-disposition header ${contentDispositionHeader} `, + ); } - return this.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, - path: `${folderPath}/${filepath}`, + return await this.treeResponseFactory.fromZipArchive({ + stream: (archiveBitbucketResponse.body as unknown) as Readable, + path: `${archiveFileName}/${filepath}`, + etag: lastCommitShortHash, filter: options?.filter, }); } @@ -141,8 +160,8 @@ export class BitbucketUrlReader implements UrlReader { return `bitbucket{host=${host},authed=${authed}}`; } - private async getLastCommitShortHash(url: string): Promise { - const { name: repoName, owner: project, ref } = parseGitUri(url); + private async getLastCommitShortHash(url: string): Promise { + const { name: repoName, owner: project, ref } = parseGitUrl(url); let branch = ref; if (!branch) { 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..81f1dfa90e 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -16,12 +16,39 @@ import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; -import { ReadTreeResponse, UrlReader } from './types'; +import { ReaderFactory, ReadTreeResponse, 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 { diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index f842adcf90..080e8b1d5b 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,11 +15,13 @@ */ import { ConfigReader } from '@backstage/config'; +import { GithubCredentialsProvider } from '@backstage/integration'; import { msw } from '@backstage/test-utils'; import fs from 'fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; +import { NotFoundError, NotModifiedError } from '../errors'; import { GithubUrlReader } from './GithubUrlReader'; import { ReadTreeResponseFactory } from './tree'; @@ -27,59 +29,193 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const mockCredentialsProvider = ({ + getCredentials: jest.fn().mockResolvedValue({ headers: {} }), +} as unknown) as GithubCredentialsProvider; + +const githubProcessor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + +const gheProcessor = new GithubUrlReader( + { + host: 'ghe.github.com', + apiBaseUrl: 'https://ghe.github.com/api/v3', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + describe('GithubUrlReader', () => { + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); await expect( - processor.read('https://not.github.com/apa'), + githubProcessor.read('https://not.github.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path', ); }); }); + describe('read', () => { + it('should use the headers from the credentials provider to the fetch request when doing read', async () => { + expect.assertions(2); + + const mockHeaders = { + Authorization: 'bearer blah', + otherheader: 'something', + }; + + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: mockHeaders, + }); + + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/tree/contents/?ref=main', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.body('foo'), + ); + }, + ), + ); + + await githubProcessor.read( + 'https://github.com/backstage/mock/tree/blob/main', + ); + }); + }); + 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}', + }; + + const reposGheApiResponse = { + ...reposGithubApiResponse, + 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}', + }; + + const branchesApiResponse = { + name: 'main', + commit: { + sha: 'etag123abc', + }, + }; + beforeEach(() => { worker.use( + rest.get('https://api.github.com/repos/backstage/mock', (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(reposGithubApiResponse), + ), + ), rest.get( - 'https://github.com/backstage/mock/archive/repo.tar.gz', + 'https://api.github.com/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchesApiResponse), + ), + ), + rest.get( + 'https://api.github.com/repos/backstage/mock/tarball/etag123abc', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), ctx.body(repoBuffer), ), ), + rest.get( + 'https://api.github.com/repos/backstage/mock/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(reposGheApiResponse), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchesApiResponse), + ), + ), ); }); it('returns the wanted files from an archive', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main', ); - const response = await processor.readTree( - 'https://github.com/backstage/mock/tree/repo', - ); + expect(response.etag).toBe('etag123abc'); const files = await response.files(); @@ -91,30 +227,49 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('includes the subdomain in the github url', async () => { - worker.resetHandlers(); + 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', - (_, res, ctx) => - res( + '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), - ), + ); + }, ), ); - const processor = new GithubUrlReader( - { - host: 'ghe.github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, + await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main', ); + }); - const response = await processor.readTree( - 'https://ghe.github.com/backstage/mock/tree/repo/docs', + 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(); @@ -125,33 +280,9 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('must specify a branch', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); - - await expect( - processor.readTree('https://github.com/backstage/mock'), - ).rejects.toThrow( - 'GitHub URL must contain branch to be able to fetch tree', - ); - }); - it('returns the wanted files from an archive with a subpath', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://github.com/backstage/mock/tree/repo/docs', + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main/docs', ); const files = await response.files(); @@ -161,5 +292,51 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).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); + }); }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 5ca2a99692..6c7cefe2ef 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -18,12 +18,12 @@ import { GitHubIntegrationConfig, readGitHubIntegrationConfigs, getGitHubFileFetchUrl, - getGitHubRequestOptions, + GithubCredentialsProvider, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { Readable } from 'stream'; -import { InputError, NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, @@ -42,7 +42,11 @@ export class GithubUrlReader implements UrlReader { config.getOptionalConfigArray('integrations.github') ?? [], ); return configs.map(provider => { - const reader = new GithubUrlReader(provider, { treeResponseFactory }); + const credentialsProvider = GithubCredentialsProvider.create(provider); + const reader = new GithubUrlReader(provider, { + treeResponseFactory, + credentialsProvider, + }); const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); @@ -50,7 +54,10 @@ export class GithubUrlReader implements UrlReader { constructor( private readonly config: GitHubIntegrationConfig, - private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + private readonly deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }, ) { if (!config.apiBaseUrl && !config.rawBaseUrl) { throw new Error( @@ -61,11 +68,17 @@ export class GithubUrlReader implements UrlReader { async read(url: string): Promise { const ghUrl = getGitHubFileFetchUrl(url, this.config); - const options = getGitHubRequestOptions(this.config); - + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); let response: Response; try { - response = await fetch(ghUrl.toString(), options); + response = await fetch(ghUrl.toString(), { + headers: { + ...headers, + Accept: 'application/vnd.github.v3.raw', + }, + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -85,44 +98,106 @@ export class GithubUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - ref, - protocol, - resource, - full_name, - filepath, - } = parseGitUri(url); + const { ref, filepath, full_name } = parseGitUrl(url); + // Caveat: The ref will totally be incorrect if the branch name includes a / + // Thus, readTree can not work on url containing branch name that has a / - 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', - ); - } + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); - // TODO(Rugvip): use API to fetch URL instead - const response = await fetch( - new URL( - `${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`, - ).toString(), - getGitHubRequestOptions(this.config), + // Get GitHub API urls for the repository + const repoGitHubResponse = await fetch( + new URL(`${this.config.apiBaseUrl}/repos/${full_name}`).toString(), + { + headers, + }, ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!repoGitHubResponse.ok) { + const message = `Failed to read tree (repository) from ${url}, ${repoGitHubResponse.status} ${repoGitHubResponse.statusText}`; + if (repoGitHubResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - const path = `${repoName}-${ref}/${filepath}`; + const repoResponseJson = await repoGitHubResponse.json(); - return this.deps.treeResponseFactory.fromTarArchive({ + // ref is an empty string if no branch is set in provided url to readTree. + // Use GitHub API to get the default branch of the repository. + const branch = ref || repoResponseJson.default_branch; + const branchesApiUrl = repoResponseJson.branches_url; + const archiveApiUrl = repoResponseJson.archive_url; + + // Fetch the latest commit in the provided or default branch to compare against + // the provided sha. + const branchGitHubResponse = await fetch( + // branchesApiUrl looks like "https://api.github.com/repos/owner/repo/branches{/branch}" + branchesApiUrl.replace('{/branch}', `/${branch}`), + { + headers, + }, + ); + if (!branchGitHubResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${branchGitHubResponse.status} ${branchGitHubResponse.statusText}`; + if (branchGitHubResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + const commitSha = (await branchGitHubResponse.json()).commit.sha; + + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + const archive = await fetch( + // archiveApiUrl looks like "https://api.github.com/repos/owner/repo/{archive_format}{/ref}" + archiveApiUrl + .replace('{archive_format}', 'tarball') + .replace('{/ref}', `/${commitSha}`), + { headers }, + ); + if (!archive.ok) { + const message = `Failed to read tree (archive) from ${url}, ${archive.status} ${archive.statusText}`; + if (archive.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + // Get the filename of archive from the header of the response + const contentDispositionHeader = archive.headers.get( + 'content-disposition', + ) as string; + if (!contentDispositionHeader) { + throw new Error( + `Failed to read tree from ${url}. ` + + 'GitHub API response for downloading archive does not contain content-disposition header ', + ); + } + const fileNameRegEx = new RegExp( + /^attachment; filename=(?.*).tar.gz$/, + ); + const archiveFileName = contentDispositionHeader.match(fileNameRegEx) + ?.groups?.fileName; + if (!archiveFileName) { + throw new Error( + `Failed to read tree from ${url}. GitHub API response for downloading archive has an unexpected ` + + `format of content-disposition header ${contentDispositionHeader} `, + ); + } + + // The path includes the name of the directory inside the tarball and a sub path + // if requested in readTree. + const path = `${archiveFileName}/${filepath}`; + + 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: (response.body as unknown) as Readable, + stream: (archive.body as unknown) as Readable, path, + etag: commitSha, filter: options?.filter, }); } diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 6cc5e30dbd..c0736f769d 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -23,6 +23,7 @@ import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError, NotFoundError } from '../errors'; const logger = getVoidLogger(); @@ -30,6 +31,22 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const gitlabProcessor = new GitlabUrlReader( + { + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + { treeResponseFactory }, +); + +const hostedGitlabProcessor = new GitlabUrlReader( + { + host: 'gitlab.mycompany.com', + apiBaseUrl: 'https://gitlab.mycompany.com/api/v4', + }, + { treeResponseFactory }, +); + describe('GitlabUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -136,39 +153,102 @@ describe('GitlabUrlReader', () => { }); describe('readTree', () => { - const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + const archiveBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), ); + const projectGitlabApiResponse = { + id: 11111111, + default_branch: 'main', + }; + + const branchGitlabApiResponse = { + commit: { + id: 'sha123abc', + }, + }; + beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/backstage/mock/-/archive/repo/mock-repo.zip', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), ), ), ); }); it('returns the wanted files from an archive', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://gitlab.com/backstage/mock/tree/repo', + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main', ); const files = await response.files(); expect(files.length).toBe(2); - const indexMarkdownFile = await files[0].content(); - const mkDocsFile = await files[1].content(); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); @@ -177,23 +257,22 @@ describe('GitlabUrlReader', () => { it('returns the wanted files from hosted gitlab', async () => { worker.use( rest.get( - 'https://git.mycompany.com/backstage/mock/-/archive/repo/mock-repo.zip', + 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), ), ), ); - const processor = new GitlabUrlReader( - { host: 'git.mycompany.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://git.mycompany.com/backstage/mock/tree/repo/docs', + const response = await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/tree/main/docs', ); const files = await response.files(); @@ -204,27 +283,9 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('throws an error when branch is not specified', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - await expect( - processor.readTree('https://gitlab.com/backstage/mock'), - ).rejects.toThrow( - 'GitLab URL must contain a branch to be able to fetch its tree', - ); - }); - it('returns the wanted files from an archive with a subpath', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://gitlab.com/backstage/mock/tree/repo/docs', + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main/docs', ); const files = await response.files(); @@ -234,5 +295,51 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).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); + }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index d51e8dc090..654f4f9a85 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -21,7 +21,7 @@ import { readGitLabIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { InputError, NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, @@ -29,7 +29,7 @@ import { ReadTreeResponse, UrlReader, } from './types'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { Readable } from 'stream'; export class GitlabUrlReader implements UrlReader { @@ -39,26 +39,26 @@ export class GitlabUrlReader implements UrlReader { const configs = readGitLabIntegrationConfigs( config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - return configs.map(options => { - const reader = new GitlabUrlReader(options, { treeResponseFactory }); - const predicate = (url: URL) => url.host === options.host; + return configs.map(provider => { + const reader = new GitlabUrlReader(provider, { treeResponseFactory }); + const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; constructor( - private readonly options: GitLabIntegrationConfig, + private readonly config: GitLabIntegrationConfig, deps: { treeResponseFactory: ReadTreeResponseFactory }, ) { this.treeResponseFactory = deps.treeResponseFactory; } async read(url: string): Promise { - const builtUrl = await getGitLabFileFetchUrl(url, this.options); + const builtUrl = await getGitLabFileFetchUrl(url, this.config); let response: Response; try { - response = await fetch(builtUrl, getGitLabRequestOptions(this.options)); + response = await fetch(builtUrl, getGitLabRequestOptions(this.config)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -78,45 +78,102 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - ref, - protocol, - resource, - full_name, - filepath, - } = parseGitUri(url); + const { ref, full_name, filepath } = parseGitUrl(url); - if (!ref) { - throw new InputError( - 'GitLab URL must contain a branch to be able to fetch its tree', - ); - } - - const archive = `${protocol}://${resource}/${full_name}/-/archive/${ref}/${repoName}-${ref}.zip`; - const response = await fetch( - archive, - getGitLabRequestOptions(this.options), + // Use GitLab API to get the default branch + // encodeURIComponent is required for GitLab API + // https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding + const projectGitlabResponse = await fetch( + new URL( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent(full_name)}`, + ).toString(), + getGitLabRequestOptions(this.config), ); - if (!response.ok) { - const msg = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!projectGitlabResponse.ok) { + const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`; + if (projectGitlabResponse.status === 404) { throw new NotFoundError(msg); } throw new Error(msg); } + const projectGitlabResponseJson = await projectGitlabResponse.json(); - const path = filepath ? `${repoName}-${ref}/${filepath}/` : ''; + // ref is an empty string if no branch is set in provided url to readTree. + const branch = ref || projectGitlabResponseJson.default_branch; - return this.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, + // Fetch the latest commit in the provided or default branch to compare against + // the provided sha. + const branchGitlabResponse = await fetch( + new URL( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/branches/${branch}`, + ).toString(), + getGitLabRequestOptions(this.config), + ); + if (!branchGitlabResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; + if (branchGitlabResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commitSha = (await branchGitlabResponse.json()).commit.id; + + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + // https://docs.gitlab.com/ee/api/repositories.html#get-file-archive + const archiveGitLabResponse = await fetch( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/archive.zip?sha=${branch}`, + getGitLabRequestOptions(this.config), + ); + if (!archiveGitLabResponse.ok) { + const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; + if (archiveGitLabResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + // Get the filename of archive from the header of the response + const contentDispositionHeader = archiveGitLabResponse.headers.get( + 'content-disposition', + ) as string; + if (!contentDispositionHeader) { + throw new Error( + `Failed to read tree from ${url}. ` + + 'GitLab API response for downloading archive does not contain content-disposition header ', + ); + } + const fileNameRegEx = new RegExp( + /^attachment; filename="(?.*).zip"$/, + ); + const archiveFileName = contentDispositionHeader.match(fileNameRegEx) + ?.groups?.fileName; + if (!archiveFileName) { + throw new Error( + `Failed to read tree from ${url}. GitLab API response for downloading archive has an unexpected ` + + `format of content-disposition header ${contentDispositionHeader} `, + ); + } + + const path = filepath ? `${archiveFileName}/${filepath}/` : ''; + + return await this.treeResponseFactory.fromZipArchive({ + stream: (archiveGitLabResponse.body as unknown) as Readable, path, + etag: commitSha, filter: options?.filter, }); } toString() { - const { host, token } = this.options; + const { host, token } = this.config; return `gitlab{host=${host},authed=${Boolean(token)}}`; } } diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts index 465c125fda..3183aa0c28 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { NotAllowedError } from '../errors'; import { ReadTreeOptions, ReadTreeResponse, @@ -21,22 +22,12 @@ import { 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 +42,25 @@ 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 Error(`No reader found that could handle '${url}'`); + throw new NotAllowedError(`Reading from '${url}' is not allowed`); } toString() { - return `predicateMux{readers=${this.readers - .map(t => t.reader) - .join(',')},fallback=${this.fallback}}`; + return `predicateMux{readers=${this.readers.map(t => t.reader).join(',')}`; } } diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 2bb5617907..2f233463bb 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -22,8 +22,8 @@ import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { FetchUrlReader } from './FetchUrlReader'; import { ReadTreeResponseFactory } from './tree'; +import { FetchUrlReader } from './FetchUrlReader'; type CreateOptions = { /** Root config object */ @@ -32,8 +32,6 @@ type CreateOptions = { logger: Logger; /** A list of factories used to construct individual readers that match on URLs */ factories?: ReaderFactory[]; - /** Fallback reader to use if none of the readers created by the factories match */ - fallback?: UrlReader; }; /** @@ -43,13 +41,8 @@ export class UrlReaders { /** * Creates a UrlReader without any known types. */ - static create({ - logger, - config, - factories, - fallback, - }: CreateOptions): UrlReader { - const mux = new UrlReaderPredicateMux({ fallback: fallback }); + static create({ logger, config, factories }: CreateOptions): UrlReader { + const mux = new UrlReaderPredicateMux(); const treeResponseFactory = ReadTreeResponseFactory.create({ config }); for (const factory of factories ?? []) { @@ -67,10 +60,8 @@ export class UrlReaders { * Creates a UrlReader that includes all the default factories from this package. * * Any additional factories passed will be loaded before the default ones. - * - * If no fallback reader is passed, a plain fetch reader will be used. */ - static default({ logger, config, factories = [], fallback }: CreateOptions) { + static default({ logger, config, factories = [] }: CreateOptions) { return UrlReaders.create({ logger, config, @@ -79,8 +70,8 @@ export class UrlReaders { BitbucketUrlReader.factory, GithubUrlReader.factory, GitlabUrlReader.factory, + FetchUrlReader.factory, ]), - fallback: fallback ?? new FetchUrlReader(), }); } } diff --git a/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz b/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz new file mode 100644 index 0000000000..e1ac2579de Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip new file mode 100644 index 0000000000..884ec20004 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz new file mode 100644 index 0000000000..291690b447 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.zip b/packages/backend-common/src/reading/__fixtures__/mock-main.zip new file mode 100644 index 0000000000..beee59d3a0 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz deleted file mode 100644 index 7a8e9902a2..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz and /dev/null differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.zip b/packages/backend-common/src/reading/__fixtures__/repo.zip deleted file mode 100644 index f66bf2d612..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.zip and /dev/null differ diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 986a0302bc..7332154d09 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -26,6 +26,8 @@ type FromArchiveOptions = { stream: Readable; // If set, the root of the tree will be set to the given directory path. path?: string; + // etag of the blob + etag: string; // Filter passed on from the ReadTreeOptions filter?: (path: string) => boolean; }; @@ -45,6 +47,7 @@ export class ReadTreeResponseFactory { options.stream, options.path ?? '', this.workDir, + options.etag, options.filter, ); } @@ -54,6 +57,7 @@ export class ReadTreeResponseFactory { options.stream, options.path ?? '', 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..2cbfc4a89e 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, 'mock-main/', '/tmp', 'etag'); const files = await res.files(); expect(files).toEqual([ @@ -61,8 +61,12 @@ 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 => - path.endsWith('.yml'), + const res = new TarArchiveResponse( + stream, + 'mock-main/', + '/tmp', + 'etag', + path => path.endsWith('.yml'), ); const files = await res.files(); @@ -79,14 +83,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, 'mock-main/', '/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 +113,26 @@ 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, 'mock-main/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, 'mock-main/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, + 'mock-main/docs/', + '/tmp', + 'etag', + ); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -135,8 +144,12 @@ 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 => - path.endsWith('.yml'), + const res = new TarArchiveResponse( + stream, + 'mock-main/', + '/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..5927eb75a1 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -41,6 +41,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 +54,8 @@ export class TarArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts index 6c2592ffce..b42ec79d81 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,31 +38,35 @@ 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, 'mock-main/', '/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 => - path.endsWith('.yml'), + const res = new ZipArchiveResponse( + stream, + 'mock-main/', + '/tmp', + 'etag', + path => path.endsWith('.yml'), ); const files = await res.files(); @@ -79,51 +83,56 @@ 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, 'mock-main/', '/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, 'mock-main/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, 'mock-main/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, + 'mock-main/docs/', + '/tmp', + 'etag', + ); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -135,8 +144,12 @@ 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 => - path.endsWith('.yml'), + const res = new ZipArchiveResponse( + stream, + 'mock-main/', + '/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..07d34faaa3 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -35,6 +35,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 +48,8 @@ export class ZipArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index f9dca3e1d5..e98f760d8f 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -32,6 +32,19 @@ export type ReadTreeOptions = { * 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; }; /** @@ -70,5 +83,14 @@ export type ReadTreeResponseDirOptions = { export type ReadTreeResponse = { 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; }; diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 6abea97454..3a33675d5f 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -22,23 +22,8 @@ export type BaseOptions = { listenHost?: string; }; -export type CertificateOptions = { - key?: CertificateKeyOptions; - attributes?: CertificateAttributeOptions; -}; - -export type CertificateKeyOptions = { - size?: number; - algorithm?: string; - days?: number; -}; - -export type CertificateAttributeOptions = { - commonName?: string; -}; - export type HttpsSettings = { - certificate: CertificateSigningOptions | CertificateReferenceOptions; + certificate: CertificateGenerationOptions | CertificateReferenceOptions; }; export type CertificateReferenceOptions = { @@ -46,11 +31,8 @@ export type CertificateReferenceOptions = { cert: string; }; -export type CertificateSigningOptions = { - algorithm?: string; - size?: number; - days?: number; - attributes: CertificateAttributes; +export type CertificateGenerationOptions = { + hostname: string; }; export type CertificateAttributes = { @@ -193,23 +175,17 @@ export function readCspOptions( * ``` */ export function readHttpsSettings(config: Config): HttpsSettings | undefined { - const https = config.get('https'); + const https = config.getOptional('https'); if (https === true) { const baseUrl = config.getString('baseUrl'); - let commonName; + let hostname; try { - commonName = new URL(baseUrl).hostname; + hostname = new URL(baseUrl).hostname; } catch (error) { throw new Error(`Invalid backend.baseUrl "${baseUrl}"`); } - return { - certificate: { - attributes: { - commonName, - }, - }, - }; + return { certificate: { hostname } }; } const cc = config.getOptionalConfig('https'); diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-common/src/service/lib/hostFactory.ts index 656f160c31..db202a84ab 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -20,10 +20,12 @@ import express from 'express'; import * as http from 'http'; import * as https from 'https'; import { Logger } from 'winston'; -import { CertificateSigningOptions, HttpsSettings } from './config'; +import { HttpsSettings } from './config'; const ALMOST_MONTH_IN_MS = 25 * 24 * 60 * 60 * 1000; +const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/; + /** * Creates a Http server instance based on an Express application. * @@ -59,17 +61,17 @@ export async function createHttpsServer( let credentials: { key: string | Buffer; cert: string | Buffer }; - const signingOptions: any = httpsSettings?.certificate; - - // TODO(Rugvip): remove support for generated certificate params and make this a more straightforward check - if (signingOptions?.attributes) { - credentials = await getGeneratedCertificate(signingOptions, logger); + if ('hostname' in httpsSettings?.certificate) { + credentials = await getGeneratedCertificate( + httpsSettings.certificate.hostname, + logger, + ); } else { logger?.info('Loading certificate from config'); credentials = { - key: signingOptions?.key, - cert: signingOptions?.cert, + key: httpsSettings?.certificate?.key, + cert: httpsSettings?.certificate?.cert, }; } @@ -80,16 +82,7 @@ export async function createHttpsServer( return https.createServer(credentials, app) as http.Server; } -async function getGeneratedCertificate( - options: CertificateSigningOptions, - logger?: Logger, -) { - if (options?.algorithm) { - logger?.warn( - 'Certificate generation configuration with parameters in backend.https.certificate is deprecated, set backend.https = true instead', - ); - } - +async function getGeneratedCertificate(hostname: string, logger?: Logger) { const hasModules = await fs.pathExists('node_modules'); let certPath; if (hasModules) { @@ -119,20 +112,61 @@ async function getGeneratedCertificate( } logger?.info('Generating new self-signed certificate'); - const newCert = await createCertificate(options); + const newCert = await createCertificate(hostname); await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); return newCert; } -async function createCertificate(options: CertificateSigningOptions) { - const attributes: Array = Object.entries( - options.attributes, - ).map(([name, value]) => ({ name, value })); +async function createCertificate(hostname: string) { + const attributes = [ + { + name: 'commonName', + value: 'dev-cert', + }, + ]; + + const sans = [ + { + type: 2, // DNS + value: 'localhost', + }, + { + type: 2, + value: 'localhost.localdomain', + }, + { + type: 2, + value: '[::1]', + }, + { + type: 7, // IP + ip: '127.0.0.1', + }, + { + type: 7, + ip: 'fe80::1', + }, + ]; + + // Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs + if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) { + sans.push( + IP_HOSTNAME_REGEX.test(hostname) + ? { + type: 7, + ip: hostname, + } + : { + type: 2, + value: hostname, + }, + ); + } const params = { - algorithm: options?.algorithm || 'sha256', - keySize: options?.size || 2048, - days: options?.days || 30, + algorithm: 'sha256', + keySize: 2048, + days: 30, extensions: [ { name: 'keyUsage', @@ -151,36 +185,7 @@ async function createCertificate(options: CertificateSigningOptions) { }, { name: 'subjectAltName', - altNames: [ - { - type: 2, // DNS - value: 'localhost', - }, - { - type: 2, - value: 'localhost.localdomain', - }, - { - type: 2, - value: '[::1]', - }, - { - type: 7, // IP - ip: '127.0.0.1', - }, - { - type: 7, - ip: 'fe80::1', - }, - ...(options.attributes.commonName - ? [ - { - type: 2, // DNS - value: options.attributes.commonName, - }, - ] - : []), - ], + altNames: sans, }, ], }; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 04cac6c2fc..1d840d8761 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,77 @@ # example-backend +## 0.2.12 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [d54857099] +- Updated dependencies [0b135e7e0] +- Updated dependencies [318a6af9f] +- Updated dependencies [294a70cab] +- Updated dependencies [ac7be581a] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [ad838c02f] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [0643a3336] +- Updated dependencies [a2291d7cc] +- Updated dependencies [f9ba00a1c] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-catalog-backend@0.5.4 + - @backstage/plugin-kubernetes-backend@0.2.5 + - @backstage/backend-common@0.5.0 + - @backstage/plugin-scaffolder-backend@0.5.0 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-auth-backend@0.2.11 + - example-app@0.2.12 + - @backstage/plugin-kafka-backend@0.1.1 + - @backstage/plugin-app-backend@0.3.4 + - @backstage/plugin-graphql-backend@0.1.5 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + +## 0.2.11 + +### Patch Changes + +- cc068c0d6: Bump the gitbeaker dependencies to 28.x. + + To update your own installation, go through the `package.json` files of all of + your packages, and ensure that all dependencies on `@gitbeaker/node` or + `@gitbeaker/core` are at version `^28.0.2`. Then run `yarn install` at the root + of your repo. + +- Updated dependencies [68ad5af51] +- Updated dependencies [5a9a7e7c2] +- Updated dependencies [f3b064e1c] +- Updated dependencies [94fdf4955] +- Updated dependencies [cc068c0d6] +- Updated dependencies [ade6b3bdf] +- Updated dependencies [468579734] +- Updated dependencies [cb7af51e7] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] +- Updated dependencies [711ba55a2] + - @backstage/plugin-techdocs-backend@0.5.3 + - @backstage/plugin-kubernetes-backend@0.2.4 + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog-backend@0.5.3 + - @backstage/plugin-scaffolder-backend@0.4.1 + - @backstage/plugin-auth-backend@0.2.10 + - @backstage/backend-common@0.4.3 + +## 0.2.10 + +### Patch Changes + +- Updated dependencies [5eb8c9b9e] +- Updated dependencies [7e3451700] + - @backstage/plugin-scaffolder-backend@0.4.0 + ## 0.2.8 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 5c13ee94e2..269089a400 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.8", + "version": "0.2.12", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,23 +27,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/plugin-app-backend": "^0.3.3", - "@backstage/plugin-auth-backend": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.1", - "@backstage/plugin-graphql-backend": "^0.1.4", - "@backstage/plugin-kubernetes-backend": "^0.2.3", - "@backstage/plugin-proxy-backend": "^0.2.3", - "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder-backend": "^0.3.6", - "@backstage/plugin-techdocs-backend": "^0.5.0", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", + "@backstage/plugin-app-backend": "^0.3.4", + "@backstage/plugin-auth-backend": "^0.2.11", + "@backstage/plugin-catalog-backend": "^0.5.4", + "@backstage/plugin-graphql-backend": "^0.1.5", + "@backstage/plugin-kubernetes-backend": "^0.2.5", + "@backstage/plugin-kafka-backend": "^0.1.1", + "@backstage/plugin-proxy-backend": "^0.2.4", + "@backstage/plugin-rollbar-backend": "^0.1.7", + "@backstage/plugin-scaffolder-backend": "^0.5.0", + "@backstage/plugin-techdocs-backend": "^0.5.4", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.8", + "example-app": "^0.2.12", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -53,11 +54,10 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.4.7", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.48" + "@types/express-serve-static-core": "^4.17.5" }, "files": [ "dist" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 68cc170901..81d0bb96d2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -38,6 +38,7 @@ import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import kubernetes from './plugins/kubernetes'; +import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; @@ -77,6 +78,7 @@ async function main() { const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); @@ -87,6 +89,7 @@ async function main() { apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/kafka', await kafka(kafkaEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); apiRouter.use(notFoundHandler()); diff --git a/packages/backend/src/plugins/kafka.ts b/packages/backend/src/plugins/kafka.ts new file mode 100644 index 0000000000..e65ce6719c --- /dev/null +++ b/packages/backend/src/plugins/kafka.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-kafka-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 5d36d508a5..4e2257a46c 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -34,6 +34,7 @@ export default async function createPlugin({ const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index c99f72e376..c8042b72e9 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.3.5 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + ## 0.3.4 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index c878119a35..0b68446463 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.7", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index f6c2477d6c..6a081c3336 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,46 @@ # @backstage/catalog-model +## 0.7.0 + +### Minor Changes + +- a93f42213: The catalog no longer attempts to merge old and new annotations, when updating an entity from a remote location. This was a behavior that was copied from kubernetes, and catered to use cases where you wanted to use HTTP POST to update an entity in-place, outside of what the refresh loop does. This has proved to be a mistake, because as a side effect, the refresh loop effectively is unable to ever delete annotations when they are removed from source YAML. This is obviously a breaking change, but we believe that this is not a behavior that is relied upon in the wild, and it has never been an actually supported use flow of the catalog. We therefore choose to break the behavior outright, and instead just store updated annotations verbatim - just like we already do for example for labels + +### Patch Changes + +- def2307f3: Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the + location that was registered to the catalog and which emitted this entity. It has a different + semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct + parent location that created this entity. + + Consider this example: The Backstage operator adds a location of type `github-org` in the + `app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor + discovers the entities in the following branch + `Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be: + + ```yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: xyz + annotations: + # This entity was added by the 'github-org:…' location + backstage.io/managed-by-location: github-org:… + # The entity was added because the 'bootstrap:boostrap' was added to the catalog + backstage.io/managed-by-origin-location: bootstrap:bootstrap + # ... + spec: + # ... + ``` + +## 0.6.1 + +### Patch Changes + +- f3b064e1c: Export the `schemaValidator` helper function. +- abbee6fff: Implement System, Domain and Resource entity kinds. +- 147fadcb9: Add subcomponentOf to Component kind to represent subsystems of larger components. + ## 0.6.0 ### Minor Changes diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index 5db5825b20..9471b810a1 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -15,4 +15,6 @@ spec: - ./components/www-artist-component.yaml - ./components/shuffle-api-component.yaml - ./components/wayback-archive-component.yaml + - ./components/wayback-archive-ingestion-component.yaml + - ./components/wayback-archive-storage-component.yaml - ./components/wayback-search-component.yaml diff --git a/packages/catalog-model/examples/all-domains.yaml b/packages/catalog-model/examples/all-domains.yaml new file mode 100644 index 0000000000..91a8a5b76d --- /dev/null +++ b/packages/catalog-model/examples/all-domains.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-domains + description: A collection of all Backstage example domains +spec: + targets: + - ./domains/artists-domain.yaml + - ./domains/playback-domain.yaml diff --git a/packages/catalog-model/examples/all-resources.yaml b/packages/catalog-model/examples/all-resources.yaml new file mode 100644 index 0000000000..d0986e3fe2 --- /dev/null +++ b/packages/catalog-model/examples/all-resources.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-resources + description: A collection of all Backstage example resources +spec: + targets: + - ./resources/artists-db-resource.yaml diff --git a/packages/catalog-model/examples/all-systems.yaml b/packages/catalog-model/examples/all-systems.yaml new file mode 100644 index 0000000000..165bee54e5 --- /dev/null +++ b/packages/catalog-model/examples/all-systems.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-systems + description: A collection of all Backstage example systems +spec: + targets: + - ./systems/artist-engagement-portal-system.yaml + - ./systems/audio-playback-system.yaml + - ./systems/podcast-system.yaml diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index 257344be3d..3fc516ece9 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -10,3 +10,4 @@ spec: type: service lifecycle: experimental owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/components/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml index f7d7670b5d..de7e93d38d 100644 --- a/packages/catalog-model/examples/components/playback-lib-component.yaml +++ b/packages/catalog-model/examples/components/playback-lib-component.yaml @@ -7,3 +7,4 @@ spec: type: library lifecycle: experimental owner: team-c + system: audio-playback diff --git a/packages/catalog-model/examples/components/playback-order-component.yaml b/packages/catalog-model/examples/components/playback-order-component.yaml index c4f41b2b58..9146063886 100644 --- a/packages/catalog-model/examples/components/playback-order-component.yaml +++ b/packages/catalog-model/examples/components/playback-order-component.yaml @@ -10,3 +10,4 @@ spec: type: service lifecycle: production owner: user:guest + system: audio-playback diff --git a/packages/catalog-model/examples/components/podcast-api-component.yaml b/packages/catalog-model/examples/components/podcast-api-component.yaml index b89ff48c48..30d254a00f 100644 --- a/packages/catalog-model/examples/components/podcast-api-component.yaml +++ b/packages/catalog-model/examples/components/podcast-api-component.yaml @@ -9,3 +9,4 @@ spec: type: service lifecycle: experimental owner: team-b + system: podcast diff --git a/packages/catalog-model/examples/components/queue-proxy-component.yaml b/packages/catalog-model/examples/components/queue-proxy-component.yaml index 7f7fcbd527..a2d5ae5ea4 100644 --- a/packages/catalog-model/examples/components/queue-proxy-component.yaml +++ b/packages/catalog-model/examples/components/queue-proxy-component.yaml @@ -10,3 +10,4 @@ spec: type: website lifecycle: production owner: team-b + system: podcast diff --git a/packages/catalog-model/examples/components/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml index 1c2da03511..6328ebdf3b 100644 --- a/packages/catalog-model/examples/components/shuffle-api-component.yaml +++ b/packages/catalog-model/examples/components/shuffle-api-component.yaml @@ -9,3 +9,4 @@ spec: type: service lifecycle: production owner: user:guest + system: audio-playback diff --git a/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml new file mode 100644 index 0000000000..4f870ac831 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-ingestion + description: Ingestion subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-d + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml new file mode 100644 index 0000000000..78d67258d7 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-storage + description: Storage subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-a + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml index c333eb8c09..3acb6fc6a6 100644 --- a/packages/catalog-model/examples/components/www-artist-component.yaml +++ b/packages/catalog-model/examples/components/www-artist-component.yaml @@ -7,3 +7,4 @@ spec: type: website lifecycle: production owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml new file mode 100644 index 0000000000..7bcc4329dd --- /dev/null +++ b/packages/catalog-model/examples/domains/artists-domain.yaml @@ -0,0 +1,7 @@ +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: artists + description: Everything related to artists +spec: + owner: team-a diff --git a/packages/catalog-model/examples/domains/playback-domain.yaml b/packages/catalog-model/examples/domains/playback-domain.yaml new file mode 100644 index 0000000000..c9933ebf5e --- /dev/null +++ b/packages/catalog-model/examples/domains/playback-domain.yaml @@ -0,0 +1,7 @@ +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: playback + description: Everything related to audio playback +spec: + owner: user:frank.tiernan diff --git a/packages/catalog-model/examples/resources/artists-db-resource.yaml b/packages/catalog-model/examples/resources/artists-db-resource.yaml new file mode 100644 index 0000000000..a666e9b3fd --- /dev/null +++ b/packages/catalog-model/examples/resources/artists-db-resource.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: artists-db + description: Stores artist details +spec: + type: database + owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml b/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml new file mode 100644 index 0000000000..8de3c00880 --- /dev/null +++ b/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: artist-engagement-portal + description: Everything related to artists + tags: + - portal +spec: + owner: team-a + domain: artists diff --git a/packages/catalog-model/examples/systems/audio-playback-system.yaml b/packages/catalog-model/examples/systems/audio-playback-system.yaml new file mode 100644 index 0000000000..7430ae2ff5 --- /dev/null +++ b/packages/catalog-model/examples/systems/audio-playback-system.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: audio-playback + description: Audio playback system +spec: + owner: team-c + domain: playback diff --git a/packages/catalog-model/examples/systems/podcast-system.yaml b/packages/catalog-model/examples/systems/podcast-system.yaml new file mode 100644 index 0000000000..47a2f7ac9f --- /dev/null +++ b/packages/catalog-model/examples/systems/podcast-system.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: podcast + description: Podcast playback +spec: + owner: team-b + domain: playback diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 4fad95e122..e03ce8c465 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.6.0", + "version": "0.7.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -38,7 +38,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.7", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/util.test.ts b/packages/catalog-model/src/entity/util.test.ts index e4399bbe05..c7e2c036b5 100644 --- a/packages/catalog-model/src/entity/util.test.ts +++ b/packages/catalog-model/src/entity/util.test.ts @@ -96,18 +96,12 @@ describe('util', () => { b = lodash.cloneDeep(a); b.metadata.labels.labelKey += 'a'; expect(entityHasChanges(a, b)).toBe(true); - }); - - it('detects annotation changes, but not removals', () => { - let b: any = lodash.cloneDeep(a); + b = lodash.cloneDeep(a); b.metadata.annotations.annotationKey += 'a'; expect(entityHasChanges(a, b)).toBe(true); b = lodash.cloneDeep(a); - b.metadata.annotations.n = 'n'; - expect(entityHasChanges(a, b)).toBe(true); - b = lodash.cloneDeep(a); delete b.metadata.annotations.annotationKey; - expect(entityHasChanges(a, b)).toBe(false); + expect(entityHasChanges(a, b)).toBe(true); }); it('detects spec changes', () => { diff --git a/packages/catalog-model/src/entity/util.ts b/packages/catalog-model/src/entity/util.ts index ed68339a99..2c63cc4c49 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -54,10 +54,6 @@ export function generateEntityEtag(): string { * @param next The new state of the entity */ export function entityHasChanges(previous: Entity, next: Entity): boolean { - if (entityHasAnnotationChanges(previous, next)) { - return true; - } - const e1 = lodash.cloneDeep(previous); const e2 = lodash.cloneDeep(next); @@ -67,6 +63,18 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { if (!e2.metadata.labels) { e2.metadata.labels = {}; } + if (!e1.metadata.annotations) { + e1.metadata.annotations = {}; + } + if (!e2.metadata.annotations) { + e2.metadata.annotations = {}; + } + if (!e1.metadata.tags) { + e1.metadata.tags = []; + } + if (!e2.metadata.tags) { + e2.metadata.tags = []; + } // Remove generated fields delete e1.metadata.uid; @@ -76,10 +84,6 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { delete e2.metadata.etag; delete e2.metadata.generation; - // Remove already compared things - delete e1.metadata.annotations; - delete e2.metadata.annotations; - // Remove things that we explicitly do not compare delete e1.relations; delete e2.relations; @@ -106,14 +110,6 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { const result = lodash.cloneDeep(next); - // Annotations are merged, with the new ones taking precedence - if (previous.metadata.annotations) { - next.metadata.annotations = { - ...previous.metadata.annotations, - ...next.metadata.annotations, - }; - } - // Generated fields are copied and updated const bumpEtag = entityHasChanges(previous, result); const bumpGeneration = !lodash.isEqual(previous.spec, result.spec); @@ -123,26 +119,3 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { return result; } - -function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean { - // Since the next annotations get merged into the previous, extract only - // the overlapping keys and check if their values match. - if (next.metadata.annotations) { - if (!previous.metadata.annotations) { - return true; - } - if ( - !lodash.isEqual( - next.metadata.annotations, - lodash.pick( - previous.metadata.annotations, - Object.keys(next.metadata.annotations), - ), - ) - ) { - return true; - } - } - - return false; -} diff --git a/packages/catalog-model/src/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..2c634ff091 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -30,6 +30,7 @@ const schema = yup.object>({ lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), definition: yup.string().required().min(1), + system: yup.string().notRequired().min(1), }) .required(), }); @@ -42,6 +43,7 @@ export interface ApiEntityV1alpha1 extends Entity { lifecycle: string; owner: string; definition: string; + system?: string; }; } diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 030c99c151..9284a5d5b1 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -33,8 +33,10 @@ describe('ComponentV1alpha1Validator', () => { type: 'service', lifecycle: 'production', owner: 'me', + subcomponentOf: 'monolith', providesApis: ['api-0'], consumesApis: ['api-0'], + system: 'system', }, }; }); @@ -103,6 +105,21 @@ describe('ComponentV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/owner/); }); + it('accepts missing subcomponentOf', async () => { + delete (entity as any).spec.subcomponentOf; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = 7; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); + }); + + it('rejects empty subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = ''; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); + }); + it('accepts missing providesApis', async () => { delete (entity as any).spec.providesApis; await expect(validator.check(entity)).resolves.toBe(true); @@ -142,4 +159,19 @@ describe('ComponentV1alpha1Validator', () => { (entity as any).spec.consumesApis = []; await expect(validator.check(entity)).resolves.toBe(true); }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index e511dcb24a..c55c48055a 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -29,8 +29,10 @@ const schema = yup.object>({ type: yup.string().required().min(1), lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), + subcomponentOf: yup.string().notRequired().min(1), providesApis: yup.array(yup.string().required()).notRequired(), consumesApis: yup.array(yup.string().required()).notRequired(), + system: yup.string().notRequired().min(1), }) .required(), }); @@ -42,8 +44,10 @@ export interface ComponentEntityV1alpha1 extends Entity { type: string; lifecycle: string; owner: string; + subcomponentOf?: string; providesApis?: string[]; consumesApis?: string[]; + system?: string; }; } 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..60b11aa124 --- /dev/null +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.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 * as yup from 'yup'; +import type { Entity } from '../entity/Entity'; +import { schemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'Domain' as const; + +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + owner: yup.string().required().min(1), + }) + .required(), +}); + +export interface DomainEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + owner: string; + }; +} + +export const domainEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); 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..12df7f6664 --- /dev/null +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.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 * as yup from 'yup'; +import type { Entity } from '../entity/Entity'; +import { schemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'Resource' 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), + owner: yup.string().required().min(1), + system: yup.string().notRequired().min(1), + }) + .required(), +}); + +export interface ResourceEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + type: string; + owner: string; + system?: string; + }; +} + +export const resourceEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); 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..764514efdd --- /dev/null +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.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. + */ + +import * as yup from 'yup'; +import type { Entity } from '../entity/Entity'; +import { schemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'System' as const; + +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + owner: yup.string().required().min(1), + domain: yup.string().notRequired().min(1), + }) + .required(), +}); + +export interface SystemEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + owner: string; + domain?: string; + }; +} + +export const systemEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 914d7efea7..bc157c79df 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +export { schemaValidator } from './util'; +export type { KindValidator } from './types'; export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, @@ -24,6 +26,11 @@ export type { ComponentEntityV1alpha1 as ComponentEntity, ComponentEntityV1alpha1, } from './ComponentEntityV1alpha1'; +export { domainEntityV1alpha1Validator } from './DomainEntityV1alpha1'; +export type { + DomainEntityV1alpha1 as DomainEntity, + DomainEntityV1alpha1, +} from './DomainEntityV1alpha1'; export { groupEntityV1alpha1Validator } from './GroupEntityV1alpha1'; export type { GroupEntityV1alpha1 as GroupEntity, @@ -35,12 +42,21 @@ export type { LocationEntityV1alpha1, } from './LocationEntityV1alpha1'; export * from './relations'; +export { resourceEntityV1alpha1Validator } from './ResourceEntityV1alpha1'; +export type { + ResourceEntityV1alpha1 as ResourceEntity, + ResourceEntityV1alpha1, +} from './ResourceEntityV1alpha1'; +export { systemEntityV1alpha1Validator } from './SystemEntityV1alpha1'; +export type { + SystemEntityV1alpha1 as SystemEntity, + SystemEntityV1alpha1, +} from './SystemEntityV1alpha1'; export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1'; export type { TemplateEntityV1alpha1 as TemplateEntity, TemplateEntityV1alpha1, } from './TemplateEntityV1alpha1'; -export type { KindValidator } from './types'; export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts index 3d5d629b9e..8ad5017fba 100644 --- a/packages/catalog-model/src/kinds/relations.ts +++ b/packages/catalog-model/src/kinds/relations.ts @@ -30,7 +30,7 @@ export const RELATION_OWNED_BY = 'ownedBy'; export const RELATION_OWNER_OF = 'ownerOf'; /** - * A relation with an API entity, typically from a component or system + * A relation with an API entity, typically from a component */ export const RELATION_CONSUMES_API = 'consumesApi'; export const RELATION_API_CONSUMED_BY = 'apiConsumedBy'; @@ -55,3 +55,10 @@ export const RELATION_CHILD_OF = 'childOf'; */ export const RELATION_MEMBER_OF = 'memberOf'; export const RELATION_HAS_MEMBER = 'hasMember'; + +/** + * A part/whole relation, typically for components in a system and systems + * in a domain. + */ +export const RELATION_PART_OF = 'partOf'; +export const RELATION_HAS_PART = 'hasPart'; diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts index 371d095685..93f2fabea4 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -15,3 +15,5 @@ */ export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; +export const ORIGIN_LOCATION_ANNOTATION = + 'backstage.io/managed-by-origin-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index ce64b988a6..8fd516120a 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -20,4 +20,4 @@ export { locationSpecSchema, analyzeLocationSchema, } from './validation'; -export { LOCATION_ANNOTATION } from './annotation'; +export { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION } from './annotation'; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d88220cc23..7998d408e0 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/cli +## 0.4.7 + +### Patch Changes + +- b604a9d41: Append `-credentials.yaml` to credentials file generated by `backstage-cli create-github-app` and display warning about sensitive contents. + +## 0.4.6 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- 08e9893d2: Handle no npm info +- 9cf71f8bf: Added experimental `create-github-app` command. + +## 0.4.5 + +### Patch Changes + +- 37a7d26c4: Use consistent file extensions for JS output when building packages. +- 818d45e94: Fix detection of external package child directories +- 0588be01f: Add `backend:bundle` command for bundling a backend package with dependencies into a deployment archive. +- b8abdda57: Add color to output from `versions:bump` in order to make it easier to spot changes. Also highlight possible breaking changes and link to changelogs. +- Updated dependencies [ad5c56fd9] + - @backstage/config-loader@0.4.1 + ## 0.4.4 ### Patch Changes @@ -10,7 +35,7 @@ ### Patch Changes -- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` - 7d72f9b09: Fix for `app.listen.host` configuration not properly overriding listening host. ## 0.4.2 diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 67f0a49b9f..7acb538d38 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -34,12 +34,12 @@ 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, + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-redeclare': 'off', '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 +79,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..044d737d15 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,9 @@ 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, - + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-redeclare': 'off', + 'no-undef': 'off', 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ @@ -90,6 +93,8 @@ module.exports = { rules: { // Default to not enforcing prop-types in typescript 'react/prop-types': 0, + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', }, }, { diff --git a/packages/cli/package.json b/packages/cli/package.json index 6da871e74c..0d1605f752 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.4.4", + "version": "0.4.7", "private": false, "publishConfig": { "access": "public" @@ -30,10 +30,11 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.0", + "@backstage/config-loader": "^0.4.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", + "@octokit/request": "^5.4.12", "@rollup/plugin-commonjs": "^16.0.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^9.0.0", @@ -49,8 +50,8 @@ "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^2.5.0", - "@typescript-eslint/eslint-plugin": "^v3.10.1", - "@typescript-eslint/parser": "^v3.10.1", + "@typescript-eslint/eslint-plugin": "^v4.14.0", + "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", "bfj": "^7.0.2", "chalk": "^4.0.0", @@ -69,6 +70,7 @@ "eslint-plugin-monorepo": "^0.3.2", "eslint-plugin-react": "^7.12.4", "eslint-plugin-react-hooks": "^4.0.0", + "express": "^4.17.1", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", @@ -111,13 +113,14 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.5.0", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/dev-utils": "^0.1.7", + "@backstage/core": "^0.5.0", + "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@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", @@ -125,7 +128,6 @@ "@types/mini-css-extract-plugin": "^0.9.1", "@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", diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts index 4d298f3735..25322045e1 100644 --- a/packages/cli/src/commands/backend/bundle.ts +++ b/packages/cli/src/commands/backend/bundle.ts @@ -38,7 +38,7 @@ export default async (cmd: Command) => { try { await createDistWorkspace([pkg.name], { targetDir: tmpDir, - buildDependencies: Boolean(cmd.build), + buildDependencies: Boolean(cmd.buildDependencies), buildExcludes: [pkg.name], parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), skeleton: SKELETON_FILE, diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 8be88adce7..56dcd3f753 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); 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/index.ts b/packages/cli/src/commands/index.ts index e090c454f9..1d267c6889 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 @@ -150,6 +153,7 @@ export function registerCommands(program: CommanderStatic) { '--package ', 'Only load config schema that applies to the given package', ) + .option('--lax', 'Do not require environment variables to be set') .option('--frontend', 'Print only the frontend configuration') .option('--with-secrets', 'Include secrets in the printed configuration') .option( @@ -166,6 +170,7 @@ export function registerCommands(program: CommanderStatic) { '--package ', 'Only load config schema that applies to the given package', ) + .option('--lax', 'Do not require environment variables to be set') .option(...configOption) .description( 'Validate that the given configuration loads and matches schema', @@ -202,6 +207,13 @@ export function registerCommands(program: CommanderStatic) { .command('build-workspace ...') .description('Builds a temporary dist workspace from the provided packages') .action(lazy(() => import('./buildWorkspace').then(m => m.default))); + + program + .command('create-github-app ', { hidden: true }) + .description( + 'Create new GitHub App in your organization. This command is experimental and may change in the future.', + ) + .action(lazy(() => import('./create-github-app').then(m => m.default))); } // Wraps an action function so that it always exits and handles errors @@ -212,6 +224,7 @@ function lazy( try { const actionFunc = await getActionFunc(); await actionFunc(...args); + process.exit(0); } catch (error) { exitWithError(error); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 1325f0e2fd..987d4f6f31 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', @@ -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((...paths) => resolvePath('/', ...paths)); + 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 index b4580ea281..f3906bfd0d 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts @@ -84,6 +84,23 @@ describe('LinkedPackageResolvePlugin', () => { expect(callbackFalse).toHaveBeenCalledWith(); expect(doResolve).toHaveBeenCalledTimes(0); + // Internal modules with a path prefix of an external module + const callbackY = jest.fn(); + tap( + { + request: path.resolve(root, 'external-aa/src/module.ts'), + path: path.resolve(root, 'external-aa/src'), + context: { + issuer: path.resolve(root, 'external-aa/src/index.ts'), + }, + }, + 'some-context', + callbackY, + ); + expect(callbackY).toHaveBeenCalledTimes(1); + expect(callbackY).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + // External modules have their path and issuer context rewritten, but not the request const callbackA = jest.fn(); tap( diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 86269a5828..0478337c61 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -16,6 +16,7 @@ import { resolve as resolvePath } from 'path'; import { ResolvePlugin } from 'webpack'; +import { isChildPath } from './paths'; import { LernaPackage } from './types'; // Enables proper resolution of packages when linking in external packages. @@ -40,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin { callback: () => void, ) => { const pkg = this.packages.find( - pkg => data.path && data.path.startsWith(pkg.location), + pkg => data.path && isChildPath(pkg.location, data.path), ); if (!pkg) { callback(); diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 3c336401ee..c53a8329f5 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -24,7 +24,7 @@ import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths } from './paths'; +import { BundlingPaths, isChildPath } from './paths'; import { transforms } from './transforms'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; @@ -87,7 +87,9 @@ export async function createConfig( const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); @@ -199,7 +201,9 @@ export async function createBackendConfig( const moduleDirs = packages.map((p: any) => resolvePath(p.location, 'node_modules'), ); - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); // See frontend config + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); // See frontend config const { loaders } = transforms(options); diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 390e41a345..f38416ca4e 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; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 88aac1e33c..30469421d3 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -21,6 +21,7 @@ import { paths } from './paths'; type Options = { args: string[]; fromPackage?: string; + mockEnv?: boolean; }; export async function loadCliConfig(options: Options) { @@ -40,7 +41,9 @@ export async function loadCliConfig(options: Options) { }); const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', + experimentalEnvFunc: options.mockEnv + ? async name => process.env[name] || 'x' + : undefined, configRoot: paths.targetRoot, configPaths, }); diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index a1eab4c9e5..110a095fe3 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -44,3 +44,5 @@ export function exitWithError(error: Error): never { process.exit(1); } } + +export class NotFoundError extends CustomError {} diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index b0f1c46e8a..5af040809a 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -19,6 +19,7 @@ import path from 'path'; import * as runObj from '../run'; import { paths } from '../paths'; import { fetchPackageInfo, mapDependencies } from './packages'; +import { NotFoundError } from '../errors'; describe('fetchPackageInfo', () => { afterEach(() => { @@ -40,6 +41,14 @@ describe('fetchPackageInfo', () => { 'my-package', ); }); + + it('should throw if no info', async () => { + jest.spyOn(runObj, 'runPlain').mockResolvedValue(''); + + await expect(fetchPackageInfo('my-package')).rejects.toThrow( + new NotFoundError(`No package information found for package my-package`), + ); + }); }); describe('mapDependencies', () => { diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 76e5dc49b0..991a4d23ef 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -15,6 +15,7 @@ */ import { runPlain } from '../../lib/run'; +import { NotFoundError } from '../errors'; const PREFIX = '@backstage'; @@ -49,6 +50,11 @@ export async function fetchPackageInfo( name: string, ): Promise { const output = await runPlain('yarn', 'info', '--json', name); + + if (!output) { + throw new NotFoundError(`No package information found for package ${name}`); + } + const info = JSON.parse(output) as YarnInfo; if (info.type !== 'inspect') { throw new Error(`Received unknown yarn info for ${name}, ${output}`); diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 30891c2dd9..a5e34ad91e 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/config-loader +## 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..476c57b014 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.4.1", "private": false, "publishConfig": { "access": "public", @@ -36,7 +36,7 @@ "fs-extra": "^9.0.0", "json-schema": "^0.2.5", "json-schema-merge-allof": "^0.7.0", - "typescript-json-schema": "^0.45.0", + "typescript-json-schema": "^0.47.0", "yaml": "^1.9.2", "yup": "^0.29.3" }, diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index ceb7c34222..192ac81f5d 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { readConfigFile } from './reader'; export { readEnvConfig } from './env'; -export { readSecret } from './secrets'; +export * from './transform'; export * from './schema'; diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts deleted file mode 100644 index a0a8495714..0000000000 --- a/packages/config-loader/src/lib/reader.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { readConfigFile } from './reader'; -import { ReaderContext, ReadSecretFunc } from './types'; - -function memoryFiles(files: { [path: string]: string }) { - return async (path: string) => { - if (path in files) { - return files[path]; - } - throw new Error(`File not found, ${path}`); - }; -} - -const mockContext: ReaderContext = { - env: {}, - readFile: jest.fn(), - readSecret: jest.fn(), -}; - -describe('readConfigFile', () => { - it('should read a plain config file', async () => { - const readFile = memoryFiles({ - './app-config.yaml': - 'app: { title: "Test", x: 1, y: [null, true], z: null }', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).resolves.toEqual({ - data: { - app: { - title: 'Test', - x: 1, - y: [true], - }, - }, - context: 'app-config.yaml', - }); - }); - - it('should error out if the config file has invalid syntax', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { title: ]', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).rejects.toThrow('Flow map contains an unexpected ]'); - }); - - it('should error out if config is not an object', async () => { - const readFile = memoryFiles({ - './app-config.yaml': '[]', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).rejects.toThrow('Expected object at config root'); - }); - - it('should read secrets', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $file: "./my-secret" }', - }); - const readSecret = jest.fn().mockResolvedValue('secret'); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - await expect(config).resolves.toEqual({ - data: { - app: 'secret', - }, - context: 'app-config.yaml', - }); - expect(readSecret).toHaveBeenCalledWith('.app', { - file: './my-secret', - }); - }); - - it('should not allow keys adjacent to secrets', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { extraKey: 3, $file: "./my-secret" }', - }); - const readSecret = jest.fn().mockResolvedValue('secret'); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - await expect(config).rejects.toThrow( - "Secret key '$file' has adjacent keys at .app", - ); - expect(readSecret).not.toHaveBeenCalled(); - }); - - it('should read deprecated secrets', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $secret: { file: "./my-secret" } }', - }); - const readSecret = jest.fn().mockResolvedValue('secret'); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - await expect(config).resolves.toEqual({ - data: { - app: 'secret', - }, - context: 'app-config.yaml', - }); - expect(readSecret).toHaveBeenCalledWith('.app', { - file: './my-secret', - }); - }); - - it('should require deprecated secrets to be objects', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $secret: ["wrong-type"] }', - }); - const readSecret = jest.fn().mockResolvedValue('secret'); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - expect(readSecret).not.toHaveBeenCalled(); - await expect(config).rejects.toThrow( - 'Expected object at secret .app.$secret', - ); - }); - - it('should forward secret reading errors', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $secret: {} }', - }); - const readSecret = jest.fn().mockRejectedValue(new Error('NOPE')); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - await expect(config).rejects.toThrow('Invalid secret at .app: NOPE'); - }); -}); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts deleted file mode 100644 index 9eba58be97..0000000000 --- a/packages/config-loader/src/lib/reader.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { AppConfig, JsonObject, JsonValue } from '@backstage/config'; -import { basename } from 'path'; -import yaml from 'yaml'; -import { ReaderContext } from './types'; -import { isObject } from './utils'; - -/** - * Reads and parses, and validates, and transforms a single config file. - * The transformation rewrites any special values, like the $secret key. - */ -export async function readConfigFile( - filePath: string, - ctx: ReaderContext, -): Promise { - const configYaml = await ctx.readFile(filePath); - const config = yaml.parse(configYaml); - - const context = basename(filePath); - - async function transform( - obj: JsonValue, - path: string, - ): Promise { - if (typeof obj !== 'object') { - return obj; - } else if (obj === null) { - return undefined; - } else if (Array.isArray(obj)) { - const arr = new Array(); - - for (const [index, value] of obj.entries()) { - const out = await transform(value, `${path}[${index}]`); - if (out !== undefined) { - arr.push(out); - } - } - - return arr; - } - - // TODO(Rugvip): This form of declaring secrets is deprecated, warn and remove in the future - if ('$secret' in obj) { - console.warn( - `Deprecated secret declaration at '${path}' in '${context}', use $env, $file, etc. instead`, - ); - if (!isObject(obj.$secret)) { - throw TypeError(`Expected object at secret ${path}.$secret`); - } - - try { - return await ctx.readSecret(path, obj.$secret); - } catch (error) { - throw new Error(`Invalid secret at ${path}: ${error.message}`); - } - } - - // Check if there's any key that starts with a '$', in that case we treat - // this entire object as a secret. - const [secretKey] = Object.keys(obj).filter(key => key.startsWith('$')); - if (secretKey) { - if (Object.keys(obj).length !== 1) { - throw new Error( - `Secret key '${secretKey}' has adjacent keys at ${path}`, - ); - } - try { - return await ctx.readSecret(path, { - [secretKey.slice(1)]: obj[secretKey], - }); - } catch (error) { - throw new Error(`Invalid secret at ${path}: ${error.message}`); - } - } - - const out: JsonObject = {}; - - for (const [key, value] of Object.entries(obj)) { - // undefined covers optional fields - if (value !== undefined) { - const result = await transform(value, `${path}.${key}`); - if (result !== undefined) { - out[key] = result; - } - } - } - - return out; - } - - const finalConfig = await transform(config, ''); - if (!isObject(finalConfig)) { - throw new TypeError('Expected object at config root'); - } - return { data: finalConfig, context }; -} diff --git a/packages/config-loader/src/lib/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/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 3f412f3e17..a40e25b2a8 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core-api +## 0.2.9 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + ## 0.2.8 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index a818d38143..85f9d3a921 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.8", + "version": "0.2.9", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,6 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.4.5", + "@backstage/test-utils": "^0.1.6", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index dfa08130cd..6205f8e058 100644 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -18,7 +18,7 @@ import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; /** - * Mirrors the javascript Error class, for the purpose of + * Mirrors the JavaScript Error class, for the purpose of * providing documentation and optional fields. */ type Error = { diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index dd6d7960ac..cc168707be 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -68,9 +68,6 @@ export class PluginImpl< options, }); }, - registerRoute(path, component, options) { - outputs.push({ type: 'legacy-route', path, component, options }); - }, }, featureFlags: { register(name) { diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 94bd4e8172..711246f1f4 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -99,26 +99,22 @@ export type PluginConfig< }; export type PluginHooks = { + /** + * @deprecated All router hooks have been deprecated + */ router: RouterHooks; featureFlags: FeatureFlagsHooks; }; export type RouterHooks = { + /** + * @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins + */ addRoute( target: RouteRef, Component: ComponentType, options?: RouteOptions, ): void; - - /** - * @deprecated See the `addRoute` method - * @see https://github.com/backstage/backstage/issues/418 - */ - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; }; export type FeatureFlagsHooks = { diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bb13447f94..0fea08e539 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,71 @@ # @backstage/core +## 0.5.0 + +### Minor Changes + +- efd6ef753: Removed `InfoCard` variant `height100`, originally deprecated in [#2826](https://github.com/backstage/backstage/pull/2826). + + If your component still relies on this variant, simply replace it with `gridItem`. + +- a187b8ad0: Removed deprecated `router.registerRoute` method in `createPlugin`. + + Deprecated `router.addRoute` method in `createPlugin`. + + Replace usage of the above two components with a routable extension. + + For example, given the following: + + ```ts + import { createPlugin } from '@backstage/core'; + import { MyPage } from './components/MyPage'; + import { rootRoute } from './routes'; + + export const plugin = createPlugin({ + id: 'my-plugin', + register({ router }) { + router.addRoute(rootRoute, MyPage); + }, + }); + ``` + + Migrate to + + ```ts + import { createPlugin, createRoutableExtension } from '@backstage/core'; + import { rootRoute } from './routes'; + + export const plugin = createPlugin({ + id: 'my-plugin', + routes: { + root: rootRoute, + }, + }); + + export const MyPage = plugin.provide( + createRoutableExtension({ + component: () => import('./components/MyPage').then(m => m.MyPage), + mountPoint: rootRoute, + }), + ); + ``` + + And then use `MyPage` like this in the app: + + ```tsx + + ... + }> + ... + + ``` + +## 0.4.4 + +### Patch Changes + +- 265a7ab30: Fix issue where `SidebarItem` with `onClick` and without `to` renders an inaccessible div. It now renders a button. + ## 0.4.3 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index b84eb4ff7b..a937502247 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.4.3", + "version": "0.5.0", "private": false, "publishConfig": { "access": "public", @@ -46,7 +46,7 @@ "d3-zoom": "^2.0.0", "dagre": "^0.8.5", "qs": "^6.9.4", - "immer": "^7.0.9", + "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", @@ -65,7 +65,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.4.7", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index 4868af54bd..916259042c 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -40,6 +40,21 @@ const nestedListStyle = (theme: Theme) => }, }); +interface StyleProps extends WithStyles { + children?: React.ReactNode; +} +// Sub Components +const StyledList = withStyles( + listStyle, +)(({ classes, children }: StyleProps) => ( + {children} +)); +const StyledNestedList = withStyles( + nestedListStyle, +)(({ classes, children }: StyleProps) => ( + {children} +)); + function renderList(list: Array, nested?: boolean) { const values = list.map((item: any, index: number) => ( {toValue(item)} @@ -100,30 +115,10 @@ function toValue( return {value}; } - -function mapToItems(info: { [key: string]: string }, options: any) { - return Object.keys(info).map(key => ( - - )); -} - -interface StyleProps extends WithStyles { - children?: React.ReactNode; -} -// Sub Components -const StyledList = withStyles( - listStyle, -)(({ classes, children }: StyleProps) => ( - {children} -)); -const StyledNestedList = withStyles( - nestedListStyle, -)(({ classes, children }: StyleProps) => ( - {children} -)); const ItemValue = ({ value, options }: { value: any; options: any }) => ( {toValue(value, options)} ); + const TableItem = ({ title, value, @@ -146,6 +141,12 @@ const TableItem = ({ ); }; +function mapToItems(info: { [key: string]: string }, options: any) { + return Object.keys(info).map(key => ( + + )); +} + type Props = { metadata: { [key: string]: any }; dense?: boolean; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 852f4a1547..dbb2964845 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -40,6 +40,7 @@ import ViewColumn from '@material-ui/icons/ViewColumn'; import { isEqual, transform } from 'lodash'; import MTable, { Column, + Icons, MaterialTableProps, MTableHeader, MTableToolbar, @@ -56,58 +57,28 @@ import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree'; import { SelectProps } from '../Select/Select'; import { Filter, Filters, SelectedFilters, Without } from './Filters'; -const tableIcons = { - Add: forwardRef((props, ref: React.Ref) => ( - - )), - Check: forwardRef((props, ref: React.Ref) => ( - - )), - Clear: forwardRef((props, ref: React.Ref) => ( - - )), - Delete: forwardRef((props, ref: React.Ref) => ( - - )), - DetailPanel: forwardRef((props, ref: React.Ref) => ( +const tableIcons: Icons = { + Add: forwardRef((props, ref) => ), + Check: forwardRef((props, ref) => ), + Clear: forwardRef((props, ref) => ), + Delete: forwardRef((props, ref) => ), + DetailPanel: forwardRef((props, ref) => ( )), - Edit: forwardRef((props, ref: React.Ref) => ( - - )), - Export: forwardRef((props, ref: React.Ref) => ( - - )), - Filter: forwardRef((props, ref: React.Ref) => ( - - )), - FirstPage: forwardRef((props, ref: React.Ref) => ( - - )), - LastPage: forwardRef((props, ref: React.Ref) => ( - - )), - NextPage: forwardRef((props, ref: React.Ref) => ( - - )), - PreviousPage: forwardRef((props, ref: React.Ref) => ( + Edit: forwardRef((props, ref) => ), + Export: forwardRef((props, ref) => ), + Filter: forwardRef((props, ref) => ), + FirstPage: forwardRef((props, ref) => ), + LastPage: forwardRef((props, ref) => ), + NextPage: forwardRef((props, ref) => ), + PreviousPage: forwardRef((props, ref) => ( )), - ResetSearch: forwardRef((props, ref: React.Ref) => ( - - )), - Search: forwardRef((props, ref: React.Ref) => ( - - )), - SortArrow: forwardRef((props, ref: React.Ref) => ( - - )), - ThirdStateCheck: forwardRef((props, ref: React.Ref) => ( - - )), - ViewColumn: forwardRef((props, ref: React.Ref) => ( - - )), + ResetSearch: forwardRef((props, ref) => ), + Search: forwardRef((props, ref) => ), + SortArrow: forwardRef((props, ref) => ), + ThirdStateCheck: forwardRef((props, ref) => ), + ViewColumn: forwardRef((props, ref) => ), }; // TODO: Material table might already have such a function internally that we can use? diff --git a/packages/core/src/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/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index 5d77f4f34a..af10010d14 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -76,26 +76,11 @@ const VARIANT_STYLES = { height: 'calc(100% - 10px)', // for pages without content header marginBottom: '10px', }, - /** - * @deprecated This variant is replaced by 'gridItem'. - */ - height100: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 10px)', // for pages without content header - marginBottom: '10px', - }, }, cardContent: { fullHeight: { flex: 1, }, - /** - * @deprecated This variant is replaced by 'gridItem'. - */ - height100: { - flex: 1, - }, gridItem: { flex: 1, }, @@ -167,12 +152,6 @@ export const InfoCard = ({ if (variant) { const variants = variant.split(/[\s]+/g); variants.forEach(name => { - if (name === 'height100') { - // eslint-disable-next-line no-console - console.warn( - "Variant 'height100' of InfoCard is deprecated. Use variant 'gridItem' instead.", - ); - } calculatedStyle = { ...calculatedStyle, ...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']], diff --git a/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx new file mode 100644 index 0000000000..099a3477d3 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Items.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 { 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'; + +async function renderSidebar() { + await renderInTestApp( + + + {}} + text="Create..." + /> + , + ); + 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(); + }); + }); +}); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 929828cee4..e954dcb4f8 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,102 @@ 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; }; -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, + } = 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( + 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/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 94260dcec9..e33d8cc633 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,305 @@ # @backstage/create-app +## 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 + +## 1.0.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 + +- a284f5bc1: Due to a package name change from `@kyma-project/asyncapi-react` to + `@asyncapi/react-component` the jest configuration in the root `package.json` + has to be updated: + + ```diff + "jest": { + "transformModules": [ + - "@kyma-project/asyncapi-react + + "@asyncapi/react-component" + ] + } + ``` + +- 89278acab: Migrate to using `FlatRoutes` from `@backstage/core` for the root app routes. + + This is the first step in migrating applications as mentioned here: https://backstage.io/docs/plugins/composability#porting-existing-apps. + + To apply this change to an existing app, switch out the `Routes` component from `react-router` to `FlatRoutes` from `@backstage/core`. + This also allows you to remove any `/*` suffixes on the route paths. For example: + + ```diff + import { + OAuthRequestDialog, + SidebarPage, + createRouteRef, + + FlatRoutes, + } from '@backstage/core'; + import { AppSidebar } from './sidebar'; + -import { Route, Routes, Navigate } from 'react-router'; + +import { Route, Navigate } from 'react-router'; + import { Router as CatalogRouter } from '@backstage/plugin-catalog'; + ... + + - + + + ... + } + /> + - } /> + + } /> + ... + } /> + - + + + + ``` + +- 26d3b24f3: fix routing and config for user-settings plugin + + To make the corresponding change in your local app, add the following in your App.tsx + + ``` + import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; + ... + } /> + ``` + + and the following to your plugins.ts: + + ``` + export { plugin as UserSettings } from '@backstage/plugin-user-settings'; + ``` + +- 92dbbcedd: Add `*-credentials.yaml` to gitignore to prevent accidental commits of sensitive credential information. + + To apply this change to an existing installation, add these lines to your `.gitignore` + + ```gitignore + # Sensitive credentials + *-credentials.yaml + ``` + +- d176671d1: use `fromConfig` for all scaffolder helpers, and use the url protocol for app-config location entries. + + To apply this change to your local installation, replace the contents of your `packages/backend/src/plugins/scaffolder.ts` with the following contents: + + ```ts + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, + CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + import type { PluginEnvironment } from '../types'; + import Docker from 'dockerode'; + + export default async function createPlugin({ + logger, + config, + }: PluginEnvironment) { + const cookiecutterTemplater = new CookieCutter(); + const craTemplater = new CreateReactAppTemplater(); + const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); + + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); + + const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + entityClient, + }); + } + ``` + + This will ensure that the `scaffolder-backend` package can add handlers for the `url` protocol which is becoming the standard when registering entities in the `catalog` + +- 9d1d1138e: Ensured that versions bumps of packages used in the app template trigger a release of this package when needed. +- db05f7a35: Remove the `@types/helmet` dev dependency from the app template. This + dependency is now unused as the package `helmet` brings its own types. + + To update your existing app, simply remove the `@types/helmet` dependency from + the `package.json` of your backend package. + +- Updated dependencies [def2307f3] +- Updated dependencies [46bba09ea] +- Updated dependencies [efd6ef753] +- Updated dependencies [0b135e7e0] +- Updated dependencies [593632f07] +- Updated dependencies [2b514d532] +- Updated dependencies [318a6af9f] +- Updated dependencies [33846acfc] +- Updated dependencies [294a70cab] +- Updated dependencies [b604a9d41] +- Updated dependencies [ac7be581a] +- Updated dependencies [a187b8ad0] +- Updated dependencies [0ea032763] +- Updated dependencies [8855f61f6] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [ad838c02f] +- Updated dependencies [f04db53d7] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [0643a3336] +- Updated dependencies [debf359b5] +- Updated dependencies [a2291d7cc] +- Updated dependencies [f9ba00a1c] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-catalog-backend@0.5.4 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/core@0.5.0 + - @backstage/backend-common@0.5.0 + - @backstage/plugin-catalog@0.2.12 + - @backstage/plugin-catalog-import@0.3.5 + - @backstage/cli@0.4.7 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/plugin-scaffolder-backend@0.5.0 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-auth-backend@0.2.11 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-user-settings@0.2.4 + - @backstage/plugin-app-backend@0.3.4 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + +## 0.3.5 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- cc068c0d6: Bump the gitbeaker dependencies to 28.x. + + To update your own installation, go through the `package.json` files of all of + your packages, and ensure that all dependencies on `@gitbeaker/node` or + `@gitbeaker/core` are at version `^28.0.2`. Then run `yarn install` at the root + of your repo. + +## 0.3.4 + +### Patch Changes + +- 643dcec7c: noop release for create-app to force re-deploy + +## 0.3.3 + +### Patch Changes + +- bd9c6719f: Bumping the version for `create-app` so that we can use the latest versions of internal packages and rebuild the version which is passed to the package.json + +## 0.3.2 + +### Patch Changes + +- c2b52d9c5: Replace `register-component` plugin with new `catalog-import` plugin +- fc6839f13: Bump `sqlite3` to v5. + + To apply this change to an existing app, change the version of `sqlite3` in the `dependencies` of `packages/backend/package.json`: + + ```diff + "pg": "^8.3.0", + - "sqlite3": "^4.2.0", + + "sqlite3": "^5.0.0", + "winston": "^3.2.1" + ``` + + Note that the `sqlite3` dependency may not be preset if you chose to use PostgreSQL when creating the app. + +- 8d68e4cdc: Removed the Circle CI sidebar item, since the target page does not exist. + + To apply this change to an existing app, remove `"CircleCI"` sidebar item from `packages/app/src/sidebar.tsx`, and the `BuildIcon` import if it is unused. + +- 1773a5182: Removed lighthouse plugin from the default set up plugins, as it requires a separate Backend to function. + + To apply this change to an existing app, remove the following: + + 1. The `lighthouse` block from `app-config.yaml`. + 2. The `@backstage/plugin-lighthouse` dependency from `packages/app/package.json`. + 3. The `@backstage/plugin-lighthouse` re-export from `packages/app/src/plugins.ts`. + 4. The Lighthouse sidebar item from `packages/app/src/sidebar.tsx`, and the `RuleIcon` import if it is unused. + ## 0.3.1 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index b9743e2bc0..60dd150566 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.1", + "version": "0.3.6", "private": false, "publishConfig": { "access": "public" @@ -37,39 +37,40 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.4", - "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/plugin-api-docs": "^0.4.2", - "@backstage/plugin-app-backend": "^0.3.3", - "@backstage/plugin-auth-backend": "^0.2.8", - "@backstage/plugin-catalog": "^0.2.9", - "@backstage/plugin-catalog-backend": "^0.5.1", - "@backstage/plugin-catalog-import": "^0.3.2", - "@backstage/plugin-circleci": "^0.2.5", - "@backstage/plugin-explore": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.6", - "@backstage/plugin-lighthouse": "^0.2.6", - "@backstage/plugin-proxy-backend": "^0.2.3", - "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/plugin-search": "^0.2.4", - "@backstage/plugin-scaffolder-backend": "^0.3.6", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.2", - "@backstage/plugin-techdocs-backend": "^0.5.1", - "@backstage/plugin-user-settings": "^0.2.3", - "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "ts-node": "^8.6.2" }, + "peerDependencies": { + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", + "@backstage/cli": "^0.4.7", + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.5.0", + "@backstage/plugin-api-docs": "^0.4.3", + "@backstage/plugin-app-backend": "^0.3.4", + "@backstage/plugin-auth-backend": "^0.2.11", + "@backstage/plugin-catalog": "^0.2.13", + "@backstage/plugin-catalog-backend": "^0.5.4", + "@backstage/plugin-catalog-import": "^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-scaffolder": "^0.4.0", + "@backstage/plugin-search": "^0.2.6", + "@backstage/plugin-scaffolder-backend": "^0.5.1", + "@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", + "@backstage/test-utils": "^0.1.6", + "@backstage/theme": "^0.2.2" + }, "nodemonConfig": { "watch": "./src", "exec": "bin/backstage-create-app", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 5cbf6c77ee..a703fb9305 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -14,47 +14,49 @@ * limitations under the License. */ -/* eslint-disable import/no-extraneous-dependencies,monorepo/no-internal-import */ +/* eslint-disable monorepo/no-relative-import */ /* This is a list of all packages used by the template. If dependencies are added or removed, this list should be updated as well. -The list, and the accompanying devDependencies entries, are here to ensure correct versioning +The list, and the accompanying peerDependencies entries, are here to ensure correct versioning and bumping of this package. Without this list the version would not be bumped unless we manually trigger a release. This does not create an actual dependency on these packages and does not bring in any code. +Relative imports are used rather than package imports to make sure the packages aren't externalized. Rollup will extract the value of the version field in each package at build time without leaving any imports in place. */ -import { version as backendCommon } from '@backstage/backend-common/package.json'; -import { version as catalogModel } from '@backstage/catalog-model/package.json'; -import { version as cli } from '@backstage/cli/package.json'; -import { version as config } from '@backstage/config/package.json'; -import { version as core } from '@backstage/core/package.json'; -import { version as pluginApiDocs } from '@backstage/plugin-api-docs/package.json'; -import { version as pluginAppBackend } from '@backstage/plugin-app-backend/package.json'; -import { version as pluginAuthBackend } from '@backstage/plugin-auth-backend/package.json'; -import { version as pluginCatalog } from '@backstage/plugin-catalog/package.json'; -import { version as pluginCatalogBackend } from '@backstage/plugin-catalog-backend/package.json'; -import { version as pluginCatalogImport } from '@backstage/plugin-catalog-import/package.json'; -import { version as pluginCircleci } from '@backstage/plugin-circleci/package.json'; -import { version as pluginExplore } from '@backstage/plugin-explore/package.json'; -import { version as pluginGithubActions } from '@backstage/plugin-github-actions/package.json'; -import { version as pluginLighthouse } from '@backstage/plugin-lighthouse/package.json'; -import { version as pluginProxyBackend } from '@backstage/plugin-proxy-backend/package.json'; -import { version as pluginRollbarBackend } from '@backstage/plugin-rollbar-backend/package.json'; -import { version as pluginScaffolder } from '@backstage/plugin-scaffolder/package.json'; -import { version as pluginScaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; -import { version as pluginSearch } from '@backstage/plugin-search/package.json'; -import { version as pluginTechRadar } from '@backstage/plugin-tech-radar/package.json'; -import { version as pluginTechdocs } from '@backstage/plugin-techdocs/package.json'; -import { version as pluginTechdocsBackend } from '@backstage/plugin-techdocs-backend/package.json'; -import { version as pluginUserSettings } from '@backstage/plugin-user-settings/package.json'; -import { version as testUtils } from '@backstage/test-utils/package.json'; -import { version as theme } from '@backstage/theme/package.json'; +import { version as backendCommon } from '../../../backend-common/package.json'; +import { version as catalogModel } from '../../../catalog-model/package.json'; +import { version as cli } from '../../../cli/package.json'; +import { version as config } from '../../../config/package.json'; +import { version as core } from '../../../core/package.json'; +import { version as testUtils } from '../../../test-utils/package.json'; +import { version as theme } from '../../../theme/package.json'; + +import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.json'; +import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json'; +import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json'; +import { version as pluginCatalog } from '../../../../plugins/catalog/package.json'; +import { version as 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, diff --git a/packages/create-app/templates/default-app/.gitignore.hbs b/packages/create-app/templates/default-app/.gitignore.hbs index 5f5cc739f4..4adebc5adc 100644 --- a/packages/create-app/templates/default-app/.gitignore.hbs +++ b/packages/create-app/templates/default-app/.gitignore.hbs @@ -30,4 +30,7 @@ dist-types site # Local configuration files -*.local.yaml \ No newline at end of file +*.local.yaml + +# Sensitive credentials +*-credentials.yaml diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 7b70a2192c..d253102f71 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -65,9 +65,6 @@ techdocs: publisher: type: 'local' -lighthouse: - baseUrl: http://localhost:3003 - auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers providers: {} @@ -91,23 +88,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/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 1353e33072..3fa350fa7c 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -45,7 +45,7 @@ }, "jest": { "transformModules": [ - "@kyma-project/asyncapi-react" + "@asyncapi/react-component" ] } } diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 48ab600c97..e40ed6f03d 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 @@ -15,7 +15,6 @@ "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}", - "@backstage/plugin-lighthouse": "^{{version '@backstage/plugin-lighthouse'}}", "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", "@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}", "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 693e66e0c6..6deedcd748 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -5,16 +5,18 @@ import { OAuthRequestDialog, SidebarPage, createRouteRef, + FlatRoutes, } from '@backstage/core'; import { apis } from './apis'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; -import { Route, Routes, Navigate } from 'react-router'; +import { Route, Navigate } from 'react-router'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { SearchPage as SearchRouter } from '@backstage/plugin-search'; +import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; import { EntityPage } from './components/catalog/EntityPage'; @@ -40,13 +42,13 @@ const App = () => ( - + } /> - } /> + } /> } @@ -59,8 +61,9 @@ const App = () => ( path="/search" element={} /> + } /> {deprecatedAppRoutes} - + diff --git a/packages/create-app/templates/default-app/packages/app/src/plugins.ts b/packages/create-app/templates/default-app/packages/app/src/plugins.ts index 182ec3c51c..28b42d5be2 100644 --- a/packages/create-app/templates/default-app/packages/app/src/plugins.ts +++ b/packages/create-app/templates/default-app/packages/app/src/plugins.ts @@ -3,7 +3,8 @@ export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as GithubActions } from '@backstage/plugin-github-actions'; -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; +export { plugin as UserSettings } from '@backstage/plugin-user-settings'; + diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx index a5595e2c09..9f7973c387 100644 --- a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx @@ -3,8 +3,6 @@ import HomeIcon from '@material-ui/icons/Home'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import ExtensionIcon from '@material-ui/icons/Extension'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import BuildIcon from '@material-ui/icons/BuildRounded'; -import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import { Link, makeStyles } from '@material-ui/core'; import { NavLink } from 'react-router-dom'; @@ -22,27 +20,6 @@ import { } from '@backstage/core'; import { SidebarSearch } from '@backstage/plugin-search'; -export const AppSidebar = () => ( - - - - - {/* Global nav, not org-specific */} - - - - - - - - - {/* End global nav */} - - - - - -); const useSidebarLogoStyles = makeStyles({ root: { @@ -76,3 +53,23 @@ const SidebarLogo = () => { ); }; + +export const AppSidebar = () => ( + + + + + {/* Global nav, not org-specific */} + + + + + + + {/* End global nav */} + + + + + +); diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 2c8f460a35..3fed72f07b 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -27,8 +27,8 @@ "@backstage/plugin-proxy-backend": "^{{version '@backstage/plugin-proxy-backend'}}", "@backstage/plugin-scaffolder-backend": "^{{version '@backstage/plugin-scaffolder-backend'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", - "@octokit/rest": "^18.0.0", - "@gitbeaker/node": "^25.2.0", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -37,7 +37,7 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", {{/if}} "winston": "^3.2.1" }, @@ -45,8 +45,7 @@ "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.47" + "@types/express-serve-static-core": "^4.17.5" }, "files": [ "dist" diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 2dc69feb45..c8bd3e5012 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,21 +1,13 @@ import { CookieCutter, createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisibilityOptions, CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; import { SingleHostDiscovery } from '@backstage/backend-common'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -26,77 +18,12 @@ export default async function createPlugin({ 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(); diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 1938754169..c6c740bf39 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/dev-utils +## 0.1.8 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.1.7 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 785607c6d3..629bc7c87e 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.7", + "version": "0.1.8", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/catalog-model": "^0.6.0", - "@backstage/plugin-catalog": "^0.2.9", + "@backstage/core": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", + "@backstage/plugin-catalog": "^0.2.12", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.4.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, 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..2ddcc333d2 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, @@ -102,13 +114,12 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { }, ), ); - - Array() - .concat(Object.keys(dependencies), Object.keys(devDependencies)) - .filter(name => name.startsWith('@backstage/')) - .forEach(dep => createAppDeps.add(dep)); + appendDeps(pkg); } + // eslint-disable-next-line import/no-extraneous-dependencies + appendDeps(require('@backstage/create-app/package.json')); + print(`Preparing workspace`); await runPlain([ 'yarn', diff --git a/packages/e2e-test/src/lib/helpers.ts b/packages/e2e-test/src/lib/helpers.ts index 0b05eaf4b4..190ff403d9 100644 --- a/packages/e2e-test/src/lib/helpers.ts +++ b/packages/e2e-test/src/lib/helpers.ts @@ -152,7 +152,7 @@ export async function waitForPageWithText( // The page may not be fully loaded and hence we need to retry. let findTextAttempts = 0; - const escapedText = text.replace(/"/g, '\\"'); + const escapedText = text.replace(/"|\\/g, '\\$&'); for (;;) { try { browser.assert.evaluate( diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 5559c5230d..804fc0c63e 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,92 @@ # @backstage/integration +## 0.3.0 + +### Minor Changes + +- ed6baab66: - Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + + ```yaml + scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public + ``` + + To something that looks like this: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: + github: + visibility: public + ``` + + You can also configure multiple different hosts under the `integration` config like the following: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN + ``` + + This of course is the case for all the providers respectively. + + - Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + + - Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + + - The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + + `backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. + +### Patch Changes + +- 0b135e7e0: Add support for GitHub Apps authentication for backend plugins. + + `GithubCredentialsProvider` requests and caches GitHub credentials based on a repository or organization url. + + The `GithubCredentialsProvider` class should be considered stateful since tokens will be cached internally. + Consecutive calls to get credentials will return the same token, tokens older than 50 minutes will be considered expired and reissued. + `GithubCredentialsProvider` will default to the configured access token if no GitHub Apps are configured. + + More information on how to create and configure a GitHub App to use with backstage can be found in the documentation. + + Usage: + + ```javascript + const credentialsProvider = new GithubCredentialsProvider(config); + const { token, headers } = await credentialsProvider.getCredentials({ + url: 'https://github.com/', + }); + ``` + + Updates `GithubUrlReader` to use the `GithubCredentialsProvider`. + +- fa8ba330a: Fix GitLab API base URL and add it by default to the gitlab.com host + +## 0.2.0 + +### Minor Changes + +- 466354aaa: Build out the `ScmIntegrations` class, as well as the individual `*Integration` classes + +## 0.1.5 + +### Patch Changes + +- 036a84373: Provide support for on-prem azure devops + ## 0.1.4 ### Patch Changes diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a03a07409b..7d670ea505 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -15,31 +15,117 @@ */ export interface Config { + /** Configuration for integrations towards various external repository provider systems */ integrations?: { + /** Integration configuration for Azure */ azure?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Azure instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; + /** Integration configuration for Bitbucket */ bitbucket?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Bitbucket instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the Bitbucket API, for example https://api.bitbucket.org/2.0 + * @visibility frontend + */ apiBaseUrl?: string; + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username?: string; + /** + * Bitbucket app password used to authenticate requests. + * @visibility secret + */ + appPassword?: string; }>; + /** Integration configuration for GitHub */ github?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitHub instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the GitHub API, for example https://api.github.com + * @visibility frontend + */ apiBaseUrl?: string; - /** @visibility frontend */ + /** + * The base url for GitHub raw resources, for example https://raw.githubusercontent.com + * @visibility frontend + */ rawBaseUrl?: string; + + /** + * GitHub Apps configuration + * @visibility backend + */ + apps?: Array<{ + /** + * The numeric GitHub App ID + */ + appId: number; + /** + * The private key to use for auth against the app + * @visibility secret + */ + privateKey: string; + /** + * The secret used for webhooks + * @visibility secret + */ + webhookSecret: string; + /** + * The client ID to use + */ + clientId: string; + /** + * The client secret to use + * @visibility secret + */ + clientSecret: string; + }>; }>; + /** Integration configuration for GitLab */ gitlab?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitLab instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; }; } diff --git a/packages/integration/package.json b/packages/integration/package.json index fe5bd60905..fd5268dced 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.1.4", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,16 @@ "dependencies": { "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6", - "git-url-parse": "^11.4.3" + "git-url-parse": "^11.4.3", + "@octokit/rest": "^18.0.12", + "@octokit/auth-app": "^2.10.5", + "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.7", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", "msw": "^0.21.2" }, "files": [ diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts new file mode 100644 index 0000000000..b43e69eba4 --- /dev/null +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -0,0 +1,76 @@ +/* + * 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], i => i.config.host), + bitbucket: basicIntegrations([bitbucket], i => i.config.host), + github: basicIntegrations([github], i => i.config.host), + gitlab: basicIntegrations([gitlab], i => i.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); + }); +}); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 515a32502d..102273a03b 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -21,27 +21,64 @@ import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { ScmIntegration, - ScmIntegrationPredicateTuple, ScmIntegrationRegistry, + ScmIntegrationsGroup, } from './types'; +type IntegrationsByType = { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +}; + export class ScmIntegrations implements ScmIntegrationRegistry { + private readonly byType: IntegrationsByType; + static fromConfig(config: Config): ScmIntegrations { - return new ScmIntegrations([ - ...AzureIntegration.factory({ config }), - ...BitbucketIntegration.factory({ config }), - ...GitHubIntegration.factory({ config }), - ...GitLabIntegration.factory({ config }), - ]); + return new ScmIntegrations({ + azure: AzureIntegration.factory({ config }), + bitbucket: BitbucketIntegration.factory({ config }), + github: GitHubIntegration.factory({ config }), + gitlab: GitLabIntegration.factory({ config }), + }); } - constructor(private readonly integrations: ScmIntegrationPredicateTuple[]) {} + constructor(integrationsByType: IntegrationsByType) { + this.byType = integrationsByType; + } + + get azure(): ScmIntegrationsGroup { + return this.byType.azure; + } + + get bitbucket(): ScmIntegrationsGroup { + return this.byType.bitbucket; + } + + get github(): ScmIntegrationsGroup { + return this.byType.github; + } + + get gitlab(): ScmIntegrationsGroup { + return this.byType.gitlab; + } list(): ScmIntegration[] { - return this.integrations.map(i => i.integration); + return Object.values(this.byType).flatMap( + i => i.list() as ScmIntegration[], + ); } - byUrl(url: string): ScmIntegration | undefined { - return this.integrations.find(i => i.predicate(new URL(url)))?.integration; + byUrl(url: string | URL): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byUrl(url)) + .find(Boolean); + } + + byHost(host: string): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byHost(host)) + .find(Boolean); } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 4a0c32badb..90c098b637 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -31,8 +31,9 @@ describe('AzureIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('dev.azure.com'); }); it('returns the basics', () => { diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 84dc3800d2..446e6a9480 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -14,27 +14,32 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; export class AzureIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readAzureIntegrationConfigs( config.getOptionalConfigArray('integrations.azure') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new AzureIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new AzureIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: AzureIntegrationConfig) {} + constructor(private readonly integrationConfig: AzureIntegrationConfig) {} get type(): string { return 'azure'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): AzureIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 7900774c79..b2878af89e 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -108,6 +108,62 @@ export function getAzureDownloadUrl(url: string): string { return `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; } +/** + * Given a URL, return the API URL to fetch commits on the branch. + * + * @param url A URL pointing to a repository or a sub-path + */ +export function getAzureCommitsUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + // Remove the "GB" from "GBmain" for example. + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + !!empty || + !userOrOrg || + !project || + srcKeyword !== '_git' || + !repoName + ) { + throw new Error('Wrong Azure Devops URL'); + } + + // transform to commits api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'commits', + ].join('/'); + + const queryParams = []; + if (ref) { + queryParams.push(`searchCriteria.itemVersion.version=${ref}`); + } + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + /** * Gets the request options necessary to make requests to a given provider. * diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index 365e4cdcdc..6d57437779 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -23,4 +23,5 @@ export { getAzureDownloadUrl, getAzureFileFetchUrl, getAzureRequestOptions, + getAzureCommitsUrl, } from './core'; diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 87dbac2263..3f130a393c 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -34,8 +34,9 @@ describe('BitbucketIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('bitbucket.org'); }); it('returns the basics', () => { diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index b271e2f408..f3e69b946a 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -14,30 +14,37 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { BitbucketIntegrationConfig, readBitbucketIntegrationConfigs, } from './config'; export class BitbucketIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { const configs = readBitbucketIntegrationConfigs( config.getOptionalConfigArray('integrations.bitbucket') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new BitbucketIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new BitbucketIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: BitbucketIntegrationConfig) {} + constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {} get type(): string { return 'bitbucket'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): BitbucketIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 0c326d81cd..9056517f32 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -33,13 +33,20 @@ describe('GitHubIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('github.com'); }); it('returns the basics', () => { - const integration = new GitHubIntegration({ host: 'h.com' } as any); + const integration = new GitHubIntegration({ + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }); expect(integration.type).toBe('github'); expect(integration.title).toBe('h.com'); + expect(integration.config.host).toBe('h.com'); }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index 92c5951873..c103597d74 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -14,30 +14,35 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitHubIntegrationConfig, readGitHubIntegrationConfigs, } from './config'; export class GitHubIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readGitHubIntegrationConfigs( config.getOptionalConfigArray('integrations.github') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new GitHubIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new GitHubIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: GitHubIntegrationConfig) {} + constructor(private readonly integrationConfig: GitHubIntegrationConfig) {} get type(): string { return 'github'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): GitHubIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts new file mode 100644 index 0000000000..f708f75184 --- /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 github = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [], + token: 'fallback_token', + }); + + await expect( + github.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 github = 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( + github.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 github = GithubCredentialsProvider.create({ + host: 'github.com', + }); + + await expect( + github.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..dfb809ee5f --- /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 entierly + * 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(result => result.credentials); + if (result) { + return result.credentials!.accessToken; + } + + const errors = results.map(r => r.error); + const notNotFoundError = errors.find(err => err.name !== 'NotFoundError'); + if (notNotFoundError) { + throw notNotFoundError; + } + + return undefined; + } +} + +export type GithubCredentials = { + headers?: { [name: string]: string }; + token?: string; +}; + +// TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake +export class GithubCredentialsProvider { + static create(config: GitHubIntegrationConfig): GithubCredentialsProvider { + return new GithubCredentialsProvider( + new GithubAppCredentialsMux(config), + config.token, + ); + } + + private constructor( + private readonly githubAppCredentialsMux: GithubAppCredentialsMux, + private readonly token?: string, + ) {} + + /** + * Returns GithubCredentials for requested url. + * Consecutive calls to this method with the same url will return cached credentials. + * The shortest lifetime for a token returned is 10 minutes. + * @param opts containing the organization or repository url + * @returns {Promise} of @type {GithubCredentials}. + * @example + * const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'}) + */ + async getCredentials(opts: { url: string }): Promise { + const parsed = parseGitUrl(opts.url); + + const owner = parsed.owner || parsed.name; + const repo = parsed.owner ? parsed.name : undefined; + + let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); + if (!token) { + token = this.token; + } + + return { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + token, + }; + } +} diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 22e8ad57d8..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -58,6 +58,41 @@ export type GitHubIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The GitHub Apps configuration to use for requests to this provider. + * + * If no apps are specified, token or anonymous is used. + */ + apps?: GithubAppConfig[]; +}; + +/** + * The configuration parameters for authenticating a GitHub Application. + * A Github Apps configuration can be generated using the `backstage-cli create-github-app` command. + */ +export type GithubAppConfig = { + /** + * Unique app identifier, found at https://github.com/organizations/$org/settings/apps/$AppName + */ + appId: number; + /** + * The private key is used by the GitHub App integration to authenticate the app. + * A private key can be generated from the app at https://github.com/organizations/$org/settings/apps/$AppName + */ + privateKey: string; + /** + * Webhook secret can be configured at https://github.com/organizations/$org/settings/apps/$AppName + */ + webhookSecret: string; + /** + * Found at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientId: string; + /** + * Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientSecret: string; }; /** @@ -72,6 +107,13 @@ export function readGitHubIntegrationConfig( let apiBaseUrl = config.getOptionalString('apiBaseUrl'); let rawBaseUrl = config.getOptionalString('rawBaseUrl'); const token = config.getOptionalString('token'); + const apps = config.getOptionalConfigArray('apps')?.map(c => ({ + appId: c.getNumber('appId'), + clientId: c.getString('clientId'), + clientSecret: c.getString('clientSecret'), + webhookSecret: c.getString('webhookSecret'), + privateKey: c.getString('privateKey'), + })); if (!isValidHost(host)) { throw new Error( @@ -91,7 +133,7 @@ export function readGitHubIntegrationConfig( rawBaseUrl = GITHUB_RAW_BASE_URL; } - return { host, apiBaseUrl, rawBaseUrl, token }; + return { host, apiBaseUrl, rawBaseUrl, token, apps }; } /** diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 5f97f6980a..6491e8dcc5 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -20,3 +20,4 @@ export { } from './config'; export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; +export { GithubCredentialsProvider } from './GithubCredentialsProvider'; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 260afd23d8..8814e33302 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -31,8 +31,9 @@ describe('GitLabIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('gitlab.com'); }); it('returns the basics', () => { diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 4d035cb24e..d939917366 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -14,30 +14,35 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitLabIntegrationConfig, readGitLabIntegrationConfigs, } from './config'; export class GitLabIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readGitLabIntegrationConfigs( config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new GitLabIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new GitLabIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: GitLabIntegrationConfig) {} + constructor(private readonly integrationConfig: GitLabIntegrationConfig) {} get type(): string { return 'gitlab'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): GitLabIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index e817465b83..303b4bd270 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -31,11 +31,14 @@ describe('readGitLabIntegrationConfig', () => { buildConfig({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }), ); + expect(output).toEqual({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }); }); @@ -43,7 +46,20 @@ describe('readGitLabIntegrationConfig', () => { const output = readGitLabIntegrationConfig(buildConfig({})); expect(output).toEqual({ host: 'gitlab.com', - apiBaseUrl: 'gitlab.com/api/v4', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', + }); + }); + + it('injects the correct GitLab API base URL when missing', () => { + const output = readGitLabIntegrationConfig( + buildConfig({ host: 'gitlab.com' }), + ); + + expect(output).toEqual({ + host: 'gitlab.com', + baseUrl: 'https://gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', }); }); @@ -78,6 +94,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toContainEqual({ host: 'a.com', token: 't', + baseUrl: 'https://a.com', }); }); @@ -86,6 +103,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toEqual([ { host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', }, ]); }); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index 2d9f4b39ab..47269b4c32 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -18,7 +18,7 @@ import { Config } from '@backstage/config'; import { isValidHost } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; -const GITLAB_API_BASE_URL = 'gitlab.com/api/v4'; +const GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'; /** * The configuration parameters for a single GitLab integration. @@ -46,6 +46,14 @@ export type GitLabIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The baseUrl of this provider, e.g "https://gitlab.com", + * which is passed into the gitlab client. + * + * If no baseUrl is provided, it will default to https://${host} + */ + baseUrl?: string; }; /** @@ -59,6 +67,7 @@ export function readGitLabIntegrationConfig( const host = config.getOptionalString('host') ?? GITLAB_HOST; let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); + const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`; if (!isValidHost(host)) { throw new Error( @@ -71,7 +80,8 @@ export function readGitLabIntegrationConfig( } else if (host === GITLAB_HOST) { apiBaseUrl = GITLAB_API_BASE_URL; } - return { host, token, apiBaseUrl }; + + return { host, token, apiBaseUrl, baseUrl }; } /** @@ -89,7 +99,7 @@ export function readGitLabIntegrationConfigs( // As a convenience we always make sure there's at least an unauthenticated // reader for public gitlab repos. if (!result.some(c => c.host === GITLAB_HOST)) { - result.push({ host: GITLAB_HOST }); + result.push({ host: GITLAB_HOST, apiBaseUrl: GITLAB_API_BASE_URL }); } return result; diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index 02393f99e1..cc1c59a238 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -14,9 +14,29 @@ * limitations under the License. */ +import { ScmIntegration, ScmIntegrationsGroup } from './types'; + /** Checks whether the given url is a valid host */ export function isValidHost(url: string): boolean { const check = new URL('http://example.com'); check.host = url; return check.host === url; } + +export function basicIntegrations( + integrations: T[], + getHost: (integration: T) => string, +): ScmIntegrationsGroup { + return { + list(): T[] { + return integrations; + }, + byUrl(url: string | URL): T | undefined { + const parsed = typeof url === 'string' ? new URL(url) : url; + return integrations.find(i => getHost(i) === parsed.hostname); + }, + byHost(host: string): T | undefined { + return integrations.find(i => getHost(i) === host); + }, + }; +} diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index ae9c360980..d8fb7a14e2 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -15,11 +15,15 @@ */ import { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; /** * Encapsulates a single SCM integration. */ -export type ScmIntegration = { +export interface ScmIntegration { /** * The type of integration, e.g. "github". */ @@ -30,30 +34,43 @@ export type ScmIntegration = { * differentiate between different integrations. */ title: string; -}; +} /** - * Holds all registered SCM integrations. + * Encapsulates several integrations, that are all of the same type. */ -export type ScmIntegrationRegistry = { +export interface ScmIntegrationsGroup { /** - * Lists all registered integrations. + * Lists all registered integrations of this type. */ - list(): ScmIntegration[]; + list(): T[]; /** - * Fetches an integration by URL. + * Fetches an integration of this type by URL. * - * @param url A URL that matches a registered integration + * @param url A URL that matches a registered integration of this type */ - byUrl(url: string): ScmIntegration | undefined; -}; + byUrl(url: string | URL): T | undefined; -export type ScmIntegrationPredicateTuple = { - predicate: (url: URL) => boolean; - integration: ScmIntegration; -}; + /** + * Fetches an integration of this type by host name. + * + * @param url A host name that matches a registered integration of this type + */ + byHost(host: string): T | undefined; +} -export type ScmIntegrationFactory = (options: { +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +} + +export type ScmIntegrationsFactory = (options: { config: Config; -}) => ScmIntegrationPredicateTuple[]; +}) => ScmIntegrationsGroup; diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 1672e5a61b..7bff39d3c4 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,80 @@ # @backstage/techdocs-common +## 0.3.5 + +### Patch Changes + +- 53c9c51f2: TechDocs backend now streams files through from Google Cloud Storage to the browser, improving memory usage. +- a5e27d5c1: Create type for TechDocsMetadata (#3716) + + This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend). + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [fa8ba330a] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/integration@0.3.0 + +## 0.3.4 + +### Patch Changes + +- a594a7257: @backstage/techdocs-common can now be imported in an environment without @backstage/plugin-techdocs-backend being installed. + +## 0.3.3 + +### Patch Changes + +- 68ad5af51: Improve techdocs-common Generator API for it to be used by techdocs-cli. TechDocs generator.run function now takes + an input AND an output directory. Most probably you use techdocs-common via plugin-techdocs-backend, and so there + is no breaking change for you. + But if you use techdocs-common separately, you need to create an output directory and pass into the generator. +- 371f67ecd: fix to-string breakage of binary files +- f1e74777a: Fix bug where binary files (`png`, etc.) could not load when using AWS or GCS publisher. +- dbe4450c3: Google Cloud authentication in TechDocs has been improved. + + 1. `techdocs.publisher.googleGcs.credentials` is now optional. If it is missing, `GOOGLE_APPLICATION_CREDENTIALS` + environment variable (and some other methods) will be used to authenticate. + Read more here https://cloud.google.com/docs/authentication/production + + 2. `techdocs.publisher.googleGcs.projectId` is no longer used. You can remove it from your `app-config.yaml`. + +- 5826d0973: AWS SDK version bump for TechDocs. +- b3b9445df: AWS S3 authentication in TechDocs has been improved. + + 1. `techdocs.publisher.awsS3.bucketName` is now the only required config. `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are optional. + + 2. If `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are missing, the AWS environment variables `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` will be used. There are more better ways of setting up AWS authentication. Read the guide at https://backstage.io/docs/features/techdocs/using-cloud-storage + +- Updated dependencies [466354aaa] +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/integration@0.2.0 + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.3.2 + +### Patch Changes + +- 7ec525481: 1. Added option to use AWS S3 as a choice to store the static generated files for TechDocs. +- f8ba88ded: Fix for `integration.github.apiBaseUrl` configuration not properly overriding apiBaseUrl used by techdocs +- 00042e73c: Moving the Git actions to isomorphic-git instead of the node binding version of nodegit +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + - @backstage/integration@0.1.5 + ## 0.3.1 ### Patch Changes diff --git a/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts b/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts new file mode 100644 index 0000000000..2d2581a6cd --- /dev/null +++ b/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { S3ClientConfig } from '@aws-sdk/client-s3'; +import { EventEmitter } from 'events'; +import fs from 'fs'; + +export class S3 { + private readonly options; + + constructor(options: S3ClientConfig) { + this.options = options; + } + + headObject({ Key }: { Key: string }) { + return new Promise((resolve, reject) => { + if (fs.existsSync(Key)) { + resolve(''); + } else { + reject({ message: `The file ${Key} doest not exist.` }); + } + }); + } + + getObject({ Key }: { Key: string }) { + return new Promise((resolve, reject) => { + if (fs.existsSync(Key)) { + const emitter = new EventEmitter(); + process.nextTick(() => { + emitter.emit('data', Buffer.from(fs.readFileSync(Key))); + emitter.emit('end'); + }); + resolve({ + Body: emitter, + }); + } else { + reject({ message: `The file ${Key} doest not exist.` }); + } + }); + } + + headBucket() { + return ''; + } + + putObject() { + return ''; + } +} diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index e95cee11d0..b84018c089 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -14,7 +14,6 @@ * limitations under the License. */ type storageOptions = { - projectId?: string; keyFilename?: string; }; @@ -39,11 +38,9 @@ class Bucket { } export class Storage { - private readonly projectId; private readonly keyFilename; constructor(options: storageOptions) { - this.projectId = options.projectId; this.keyFilename = options.keyFilename; } diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 7fc88a5238..93825511cb 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.3.1", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -36,10 +36,11 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@aws-sdk/client-s3": "^3.1.0", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.1.4", + "@backstage/integration": "^0.3.0", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", @@ -48,14 +49,17 @@ "express": "^4.17.1", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.3", - "js-yaml": "^3.14.0", + "js-yaml": "^4.0.0", + "json5": "^2.1.3", "mime-types": "^2.1.27", "mock-fs": "^4.13.0", + "p-limit": "^3.1.0", "recursive-readdir": "^2.2.2", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@aws-sdk/types": "3.1.0", + "@backstage/cli": "^0.4.7", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^3.12.5", diff --git a/packages/techdocs-common/src/default-branch.ts b/packages/techdocs-common/src/default-branch.ts index 7dd460a9fe..c0ec63af10 100644 --- a/packages/techdocs-common/src/default-branch.ts +++ b/packages/techdocs-common/src/default-branch.ts @@ -54,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 { diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts index 88c4d8829e..740a08aaa2 100644 --- a/packages/techdocs-common/src/helpers.test.ts +++ b/packages/techdocs-common/src/helpers.test.ts @@ -135,6 +135,7 @@ describe('getDocFilesFromRepository', () => { archive: async () => { return Readable.from(''); }, + etag: '', }; } } diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index 83ccbbffab..4d62648a7d 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -80,14 +80,14 @@ describe('helpers', () => { const imageName = 'spotify/techdocs'; const args = ['build', '-d', '/result']; const docsDir = os.tmpdir(); - const resultDir = os.tmpdir(); + const outputDir = os.tmpdir(); it('should pull the techdocs docker container', async () => { await runDockerContainer({ imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -103,7 +103,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -118,7 +118,7 @@ describe('helpers', () => { }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, }, ); @@ -129,7 +129,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -151,7 +151,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); @@ -303,7 +303,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,10 +322,10 @@ 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', ); }); }); diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 773b542517..e396157b92 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -39,7 +39,7 @@ type RunDockerContainerOptions = { args: string[]; logStream?: Writable; docsDir: string; - resultDir: string; + outputDir: string; dockerClient: Docker; createOptions?: Docker.ContainerCreateOptions; }; @@ -56,7 +56,7 @@ export async function runDockerContainer({ args, logStream = new PassThrough(), docsDir, - resultDir, + outputDir, dockerClient, createOptions, }: RunDockerContainerOptions) { @@ -89,7 +89,7 @@ export async function runDockerContainer({ }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, ...createOptions, }, @@ -241,7 +241,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 +267,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}`, diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index faefd8ac16..507b9da6b8 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -14,18 +14,12 @@ * limitations under the License. */ -import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { Logger } from 'winston'; import { PassThrough } from 'stream'; import { Config } from '@backstage/config'; -import { - GeneratorBase, - GeneratorRunOptions, - GeneratorRunResult, -} from './types'; +import { GeneratorBase, GeneratorRunOptions } from './types'; import { runDockerContainer, runCommand, @@ -64,40 +58,37 @@ export class TechdocsGenerator implements GeneratorBase { } public async run({ - directory, + inputDir, + outputDir, dockerClient, parsedLocationAnnotation, - }: GeneratorRunOptions): Promise { - const tmpdirPath = os.tmpdir(); - // Fixes a problem with macOS returning a path that is a symlink - const tmpdirResolvedPath = fs.realpathSync(tmpdirPath); - const resultDir = fs.mkdtempSync( - path.join(tmpdirResolvedPath, 'techdocs-tmp-'), - ); + }: GeneratorRunOptions): Promise { const [log, logStream] = createStream(); // TODO: In future mkdocs.yml can be mkdocs.yaml. So, use a config variable here to find out // the correct file name. // Do some updates to mkdocs.yml before generating docs e.g. adding repo_url - await patchMkdocsYmlPreBuild( - path.join(directory, 'mkdocs.yml'), - this.logger, - parsedLocationAnnotation, - ); + if (parsedLocationAnnotation) { + await patchMkdocsYmlPreBuild( + path.join(inputDir, 'mkdocs.yml'), + this.logger, + parsedLocationAnnotation, + ); + } try { switch (this.options.runGeneratorIn) { case 'local': await runCommand({ command: 'mkdocs', - args: ['build', '-d', resultDir, '-v'], + args: ['build', '-d', outputDir, '-v'], options: { - cwd: directory, + cwd: inputDir, }, logStream, }); this.logger.info( - `Successfully generated docs from ${directory} into ${resultDir} using local mkdocs`, + `Successfully generated docs from ${inputDir} into ${outputDir} using local mkdocs`, ); break; case 'docker': @@ -105,12 +96,12 @@ export class TechdocsGenerator implements GeneratorBase { imageName: 'spotify/techdocs', args: ['build', '-d', '/result'], logStream, - docsDir: directory, - resultDir, + docsDir: inputDir, + outputDir, dockerClient, }); this.logger.info( - `Successfully generated docs from ${directory} into ${resultDir} using techdocs-container`, + `Successfully generated docs from ${inputDir} into ${outputDir} using techdocs-container`, ); break; default: @@ -120,14 +111,12 @@ export class TechdocsGenerator implements GeneratorBase { } } catch (error) { this.logger.debug( - `Failed to generate docs from ${directory} into ${resultDir}`, + `Failed to generate docs from ${inputDir} into ${outputDir}`, ); this.logger.debug(`Build failed with error: ${log}`); throw new Error( - `Failed to generate docs from ${directory} into ${resultDir} with error ${error.message}`, + `Failed to generate docs from ${inputDir} into ${outputDir} with error ${error.message}`, ); } - - return { resultDir }; } } diff --git a/packages/techdocs-common/src/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts index 65e411c3aa..7724107dac 100644 --- a/packages/techdocs-common/src/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -18,32 +18,26 @@ import Docker from 'dockerode'; import { Entity } from '@backstage/catalog-model'; import { ParsedLocationAnnotation } from '../../helpers'; -/** - * The returned directory from the generator which is ready - * to pass to the next stage of the TechDocs which is publishing - */ -export type GeneratorRunResult = { - resultDir: string; -}; - /** * The values that the generator will receive. * - * @param {string} directory The directory of the uncompiled documentation, with the values from the frontend + * @param {string} inputDir The directory of the uncompiled documentation, with the values from the frontend + * @param {string} outputDir Directory to store generated docs in. Usually - a newly created temporary directory. * @param {Docker} dockerClient A docker client to run any generator on top of your directory * @param {ParsedLocationAnnotation} parsedLocationAnnotation backstage.io/techdocs-ref annotation of an entity * @param {Writable} [logStream] A dedicated log stream */ export type GeneratorRunOptions = { - directory: string; + inputDir: string; + outputDir: string; dockerClient: Docker; - parsedLocationAnnotation: ParsedLocationAnnotation; + parsedLocationAnnotation?: ParsedLocationAnnotation; logStream?: Writable; }; export type GeneratorBase = { - // runs the generator with the values and returns the directory to be published - run(opts: GeneratorRunOptions): Promise; + // Runs the generator with the values + run(opts: GeneratorRunOptions): Promise; }; /** diff --git a/packages/techdocs-common/src/stages/prepare/index.ts b/packages/techdocs-common/src/stages/prepare/index.ts index 6c73725a3a..dcb178bafa 100644 --- a/packages/techdocs-common/src/stages/prepare/index.ts +++ b/packages/techdocs-common/src/stages/prepare/index.ts @@ -17,4 +17,4 @@ export { DirectoryPreparer } from './dir'; export { CommonGitPreparer } from './commonGit'; export { UrlPreparer } from './url'; export { Preparers } from './preparers'; -export type { PreparerBuilder, PreparerBase } from './types'; +export type { PreparerBuilder, PreparerBase, RemoteProtocol } from './types'; diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts new file mode 100755 index 0000000000..3902aab21a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 path from 'path'; +import * as winston from 'winston'; +import { ConfigReader } from '@backstage/config'; +import { AwsS3Publish } from './awsS3'; +import { PublisherBase, TechDocsMetadata } from './types'; +import type { Entity, EntityName } from '@backstage/catalog-model'; + +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 getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + const entityRootDir = path.join(namespace as string, kind, name); + return entityRootDir; +}; + +const logger = winston.createLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(() => { + 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', () => { + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: '/wrong/path/to/generatedDirectory', + }) + .catch(error => + expect(error.message).toContain( + 'Unable to upload file(s) to AWS S3. Error Failed to read template directory', + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); + + 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 { + metadata: { name, namespace }, + kind, + } = entity; + + await publisher + .fetchTechDocsMetadata(entityNameMock) + .catch(error => + expect(error).toEqual( + new Error( + `TechDocs metadata fetch failed, The file ${namespace}/${kind}/${name}/techdocs_metadata.json doest not exist.`, + ), + ), + ); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts new file mode 100644 index 0000000000..20e6bc424d --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 express from 'express'; +import { PutObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import fs from 'fs-extra'; +import { Readable } from 'stream'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; + +const streamToBuffer = (stream: Readable): Promise => { + return new Promise((resolve, reject) => { + try { + const chunks: any[] = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + } catch (e) { + throw new Error(`Unable to parse the response data ${e.message}`); + } + }); +}; + +export class AwsS3Publish implements PublisherBase { + static fromConfig(config: Config, logger: Logger): PublisherBase { + let 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, default AWS environment variables + // or AWS shared credentials file at ~/.aws/credentials will be used to authenticate + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + const credentials = config.getOptionalConfig( + 'techdocs.publisher.awsS3.credentials', + ); + let accessKeyId = undefined; + let secretAccessKey = undefined; + if (credentials) { + accessKeyId = credentials.getOptionalString('accessKeyId'); + secretAccessKey = credentials.getOptionalString('secretAccessKey'); + } + + // AWS Region is an optional config. If missing, default AWS env variable AWS_REGION + // or AWS shared credentials file at ~/.aws/credentials will be used. Any way, AWS SDK v3 client needs + // to have the AWS Region information for it to work. + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + const region = config.getOptionalString('techdocs.publisher.awsS3.region'); + + const storageClient = new S3({ + ...(credentials && + accessKeyId && + secretAccessKey && { + credentials: { + accessKeyId, + secretAccessKey, + }, + }), + ...(region && { + region, + }), + }); + + // Check if the defined bucket exists. Being able to connect means the configuration is good + // 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: 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 = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = `${entityRootDir}/${relativeFilePath}`; // S3 Bucket file relative path + + const fileContent = await fs.readFile(filePath, 'utf8'); + + const params = { + Bucket: this.bucketName, + Key: destination, + Body: fileContent, + }; + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => this.storageClient.putObject(params)); + uploadPromises.push(uploadFile); + } + await Promise.all(uploadPromises); + this.logger.info( + `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. Error ${e.message}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + try { + return await new Promise((resolve, reject) => { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + + this.storageClient + .getObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/techdocs_metadata.json`, + }) + .then(async file => { + const techdocsMetadataJson = await streamToBuffer( + file.Body as Readable, + ); + + if (!techdocsMetadataJson) { + throw new Error( + `Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`, + ); + } + const techdocsMetadata = JSON5.parse( + techdocsMetadataJson.toString('utf-8'), + ); + + resolve(techdocsMetadata); + }) + .catch(err => { + this.logger.error(err.message); + reject(new Error(err.message)); + }); + }); + } 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 (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); + + this.storageClient + .getObject({ Bucket: this.bucketName, Key: filePath }) + .then(async object => { + const fileContent = await streamToBuffer(object.Body as Readable); + if (!fileContent) { + throw new Error(`Unable to parse the file ${filePath}.`); + } + + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + + res.send(fileContent); + }) + .catch(err => { + this.logger.warn(err.message); + res.status(404).send(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`, + }); + return Promise.resolve(true); + } catch (e) { + return Promise.resolve(false); + } + } +} diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 43375b72d0..f8fb00647c 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -37,7 +37,7 @@ jest.spyOn(logger, 'info').mockReturnValue(logger); let publisher: PublisherBase; -beforeEach(() => { +beforeEach(async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', @@ -45,14 +45,13 @@ beforeEach(() => { type: 'googleGcs', googleGcs: { credentials: '{}', - projectId: 'gcp-project-id', bucketName: 'bucketName', }, }, }, }); - publisher = GoogleGCSPublish.fromConfig(mockConfig, logger); + publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger); }); describe('GoogleGCSPublish', () => { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index b33e2bfbe0..8876007504 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -24,60 +24,61 @@ import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; export class GoogleGCSPublish implements PublisherBase { - static fromConfig(config: Config, logger: Logger): PublisherBase { - let credentials = ''; - let projectId = ''; + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { let bucketName = ''; try { - credentials = config.getString( - 'techdocs.publisher.googleGcs.credentials', - ); - projectId = config.getString('techdocs.publisher.googleGcs.projectId'); bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); } catch (error) { throw new Error( "Since techdocs.publisher.type is set to 'googleGcs' in your app config, " + - 'credentials, projectId and bucketName are required in techdocs.publisher.googleGcs ' + - 'required to authenticate with Google Cloud Storage.', + 'techdocs.publisher.googleGcs.bucketName is required.', ); } + // Credentials is an optional config. If missing, default GCS environment variables will be used. + // Read more here https://cloud.google.com/docs/authentication/production + const credentials = config.getOptionalString( + 'techdocs.publisher.googleGcs.credentials', + ); let credentialsJson = {}; - try { - credentialsJson = JSON.parse(credentials); - } catch (err) { - throw new Error( - 'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.', - ); + if (credentials) { + try { + credentialsJson = JSON.parse(credentials); + } catch (err) { + throw new Error( + 'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.', + ); + } } const storageClient = new Storage({ - credentials: credentialsJson, - projectId: projectId, + ...(credentials && { + credentials: credentialsJson, + }), }); // Check if the defined bucket exists. Being able to connect means the configuration is good // and the storage client will work. - storageClient - .bucket(bucketName) - .getMetadata() - .then(() => { - logger.info( - `Successfully connected to the GCS bucket ${bucketName} in the GCP project ${projectId}.`, - ); - }) - .catch(reason => { - logger.error( - `Could not retrieve metadata about the GCS bucket ${bucketName} in the GCP project ${projectId}. ` + - 'Make sure the GCP project and the bucket exists and the access key located at the path ' + - "techdocs.publisher.googleGcs.credentials defined in app config has the role 'Storage Object Creator'. " + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(`from GCS client library: ${reason.message}`); - }); + try { + await storageClient.bucket(bucketName).getMetadata(); + logger.info(`Successfully connected to the GCS bucket ${bucketName}.`); + } catch (err) { + logger.error( + `Could not retrieve metadata about the GCS bucket ${bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + + 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(err.message); + } return new GoogleGCSPublish(storageClient, bucketName, logger); } @@ -102,6 +103,7 @@ export class GoogleGCSPublish implements PublisherBase { // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); + const limiter = createLimiter(10); const uploadPromises: Array> = []; allFilesToUpload.forEach(filePath => { // Remove the absolute path prefix of the source directory @@ -110,12 +112,14 @@ export class GoogleGCSPublish implements PublisherBase { const relativeFilePath = filePath.replace(`${directory}/`, ''); const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; const destination = `${entityRootDir}/${relativeFilePath}`; // GCS Bucket file relative path - // TODO: Upload in chunks of ~10 files instead of all files at once. - uploadPromises.push( - this.storageClient.bucket(this.bucketName).upload(filePath, { - destination, - }), + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => + this.storageClient + .bucket(this.bucketName) + .upload(filePath, { destination }), ); + uploadPromises.push(uploadFile); }); Promise.all(uploadPromises) @@ -133,7 +137,7 @@ export class GoogleGCSPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { + fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; @@ -153,7 +157,7 @@ export class GoogleGCSPublish implements PublisherBase { const techdocsMetadataJson = Buffer.concat( fileStreamChunks, ).toString(); - resolve(techdocsMetadataJson); + resolve(JSON5.parse(techdocsMetadataJson)); }); }); } @@ -171,29 +175,24 @@ export class GoogleGCSPublish implements PublisherBase { const fileExtension = path.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); - const fileStreamChunks: Array = []; + // Pipe file chunks directly from storage to client. this.storageClient .bucket(this.bucketName) .file(filePath) .createReadStream() + .on('pipe', () => { + res.writeHead(200, responseHeaders); + }) .on('error', err => { this.logger.warn(err.message); - res.status(404).send(err.message); - }) - .on('data', chunk => { - fileStreamChunks.push(chunk); - }) - .on('end', () => { - const fileContent = Buffer.concat(fileStreamChunks).toString(); - // Inject response headers - for (const [headerKey, headerValue] of Object.entries( - responseHeaders, - )) { - res.setHeader(headerKey, headerValue); + // Send a 404 with a meaningful message if possible. + if (!res.headersSent) { + res.status(404).send(err.message); + } else { + res.destroy(); } - - res.send(fileContent); - }); + }) + .pipe(res); }; } diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts index 494efe5ac1..dbf4ff732a 100644 --- a/packages/techdocs-common/src/stages/publish/index.ts +++ b/packages/techdocs-common/src/stages/publish/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ export { Publisher } from './publish'; -export type { PublisherBase, PublisherType } from './types'; +export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 2739940530..d07472e17d 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -16,6 +16,8 @@ import fetch from 'cross-fetch'; import express from 'express'; import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { @@ -23,12 +25,27 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { PublisherBase, PublishRequest, PublishResponse } from './types'; +import { + PublisherBase, + PublishRequest, + PublishResponse, + TechDocsMetadata, +} from './types'; -const staticDocsDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', -); +// TODO: Use a more persistent storage than node_modules or /tmp directory. +// Make it configurable with techdocs.publisher.local.publishDirectory +let staticDocsDir = ''; +try { + staticDocsDir = resolvePackagePath( + '@backstage/plugin-techdocs-backend', + 'static/docs', + ); +} catch (err) { + // This will most probably never be used. + // The try/catch is introduced so that techdocs-cli can import @backstage/techdocs-common + // on CI/CD without installing techdocs backend plugin. + staticDocsDir = os.tmpdir(); +} /** * Local publisher which uses the local filesystem to store the generated static files. It uses a directory @@ -39,6 +56,9 @@ export class LocalPublish implements PublisherBase { private readonly logger: Logger; private readonly discovery: PluginEndpointDiscovery; + // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. + // Move the logic of setting staticDocsDir based on config over to fromConfig, + // and set the value as a class parameter. constructor( config: Config, logger: Logger, @@ -52,9 +72,8 @@ export class LocalPublish implements PublisherBase { publish({ entity, directory }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; - const publishDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', + const publishDir = path.join( + staticDocsDir, entityNamespace, entity.kind, entity.metadata.name, @@ -88,7 +107,7 @@ export class LocalPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { + fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { const storageUrl = new URL( @@ -102,7 +121,7 @@ export class LocalPublish implements PublisherBase { .then(response => response .json() - .then(techdocsMetadataJson => resolve(techdocsMetadataJson)) + .then(techdocsMetadata => resolve(techdocsMetadata)) .catch(err => { reject( `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index c61b9aa3ec..89f2c0ffdd 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -21,6 +21,7 @@ import { ConfigReader } from '@backstage/config'; import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; +import { AwsS3Publish } from './awsS3'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -68,7 +69,6 @@ describe('Publisher', () => { type: 'googleGcs', googleGcs: { credentials: '{}', - projectId: 'gcp-project-id', bucketName: 'bucketName', }, }, @@ -81,4 +81,28 @@ describe('Publisher', () => { }); 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); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index 95b5cf83e2..82232c2fd1 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -20,6 +20,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; +import { AwsS3Publish } from './awsS3'; type factoryOptions = { logger: Logger; @@ -42,7 +43,10 @@ export class Publisher { switch (publisherType) { case 'googleGcs': logger.info('Creating Google Storage Bucket publisher for TechDocs'); - return GoogleGCSPublish.fromConfig(config, logger); + return await GoogleGCSPublish.fromConfig(config, logger); + case 'awsS3': + logger.info('Creating AWS S3 Bucket publisher for TechDocs'); + return AwsS3Publish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 9bfd8cb334..db6a075d43 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,7 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs'; +export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; export type PublishRequest = { entity: Entity; @@ -32,6 +32,14 @@ export type PublishResponse = { remoteUrl?: string; } | void; +/** + * Type to hold metadata found in techdocs_metadata.json and associated with each site + */ +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + /** * Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.) * The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs. @@ -50,7 +58,7 @@ export interface PublisherBase { * Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc. * This API uses the techdocs_metadata.json file that co-exists along with the generated docs. */ - fetchTechDocsMetadata(entityName: EntityName): Promise; + fetchTechDocsMetadata(entityName: EntityName): Promise; /** * Route middleware to serve static documentation files for an entity. diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 995dfdc175..01e34d8e1d 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-api-docs +## 0.4.3 + +### Patch Changes + +- 8855f61f6: Update `@asyncapi/react-component` to 0.18.2 +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.4.2 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 23dd66a9a9..d9c0dc0968 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.2", + "version": "0.4.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.9", + "@asyncapi/react-component": "^0.18.2", + "@backstage/catalog-model": "^0.7.0", + "@backstage/core": "^0.5.0", + "@backstage/plugin-catalog": "^0.2.12", "@backstage/theme": "^0.2.2", - "@kyma-project/asyncapi-react": "^0.14.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,8 +49,8 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.4.7", + "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/api-docs/src/components/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/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index e16ac1fb9c..90a4eac95d 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-app-backend +## 0.3.4 + +### Patch Changes + +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] + - @backstage/backend-common@0.5.0 + ## 0.3.3 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 0dde9c9d13..f55da9042a 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.0", + "@backstage/backend-common": "^0.5.0", "@backstage/config-loader": "^0.4.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.4.7", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 7d67ec0273..0df0fa1938 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,57 @@ # @backstage/plugin-auth-backend +## 0.2.11 + +### Patch Changes + +- 0643a3336: Add AWS ALB OIDC reverse proxy authentication provider +- a2291d7cc: Optional identity token authorization of api requests +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/catalog-client@0.3.5 + +## 0.2.10 + +### Patch Changes + +- 468579734: Allow blank certificates and support logout URLs in the SAML provider. +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.2.9 + +### Patch Changes + +- 0289a059c: Add support for the majority of the Core configurations for Passport-SAML. + + These configuration keys are supported: + + - entryPoint + - issuer + - cert + - privateKey + - decryptionPvk + - signatureAlgorithm + - digestAlgorithm + + As part of this change, there is also a fix to the redirection behaviour when doing load balancing and HTTPS termination - the application's baseUrl is used to generate the callback URL. For properly configured Backstage installations, no changes are necessary, and the baseUrl is respected. + +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + ## 0.2.8 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index ee6e84cded..1fea3346a7 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -10,7 +10,7 @@ to the appropriate provider in the backend. ## Local development Choose your OAuth Providers, replace `x` with actual value and then start backend: -Example for Google Oauth Provider at root directory: +Example for Google OAuth Provider at root directory: ```bash export AUTH_GOOGLE_CLIENT_ID=x @@ -89,8 +89,16 @@ export AUTH_GITLAB_CLIENT_SECRET=x ### Okta +Add a new Okta application using the following URI conventions: + +Login redirect URI's: `http://localhost:7000/api/auth/okta/handler/frame` +Logout redirect URI's: `http://localhost:7000/api/auth/okta/logout` +Initiate login URI's: `http://localhost:7000/api/auth/okta/start` + +Then configure the following environment variables to be used in the `app-config.yaml` file: + ```bash -export AUTH_OKTA_AUDIENCE=x +export AUTH_OKTA_AUDIENCE=https://example.okta.com export AUTH_OKTA_CLIENT_ID=x export AUTH_OKTA_CLIENT_SECRET=x ``` diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index c971528e14..c748090711 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -46,7 +46,13 @@ export interface Config { }; saml?: { entryPoint: string; + logoutUrl?: string; issuer: string; + cert?: string; + privateKey?: string; + decryptionPvk?: string; + signatureAlgorithm?: 'sha256' | 'sha512'; + digestAlgorithm?: string; }; okta?: { development: { [key: string]: string }; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 38aed2f792..27cb725faa 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.8", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-client": "^0.3.4", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -49,6 +49,7 @@ "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", @@ -58,24 +59,24 @@ "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", - "passport-saml": "^1.3.3", + "passport-saml": "^2.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.4.7", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", "@types/jwt-decode": "^3.1.0", - "@types/nock": "^11.1.0", - "@types/openid-client": "^3.7.0", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", + "@types/passport-strategy": "^0.2.35", + "@types/xml2js": "^0.4.7", "msw": "^0.21.2", "nock": "^13.0.5" }, diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts new file mode 100644 index 0000000000..5f317aa0c2 --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JWT, JSONWebKey } from 'jose'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { IdentityClient } from './IdentityClient'; +import { MemoryKeyStore } from './MemoryKeyStore'; +import { TokenFactory } from './TokenFactory'; +import { KeyStore } from './types'; + +const logger = getVoidLogger(); + +function jwtKid(jwt: string): string { + const { header } = JWT.decode(jwt, { complete: true }) as { + header: { kid: string }; + }; + return header.kid; +} + +const server = setupServer(); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + return mockBaseUrl; + }, +}; + +describe('IdentityClient', () => { + let client: IdentityClient; + let factory: TokenFactory; + let keyStore: KeyStore; + const keyDurationSeconds = 5; + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + beforeEach(() => { + client = new IdentityClient({ discovery, issuer: mockBaseUrl }); + keyStore = new MemoryKeyStore(); + factory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: keyStore, + keyDurationSeconds, + logger, + }); + }); + + describe('authenticate', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should use the correct endpoint', async () => { + await factory.issueToken({ claims: { sub: 'foo' } }); + const keys = await factory.listPublicKeys(); + const response = await client.listPublicKeys(); + expect(response).toEqual(keys); + }); + + it('should throw on undefined header', async () => { + return expect(async () => { + await client.authenticate(undefined); + }).rejects.toThrow(); + }); + + it('should accept fresh token', async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should throw on incorrect issuer', async () => { + const hackerFactory = new TokenFactory({ + issuer: 'hacker', + keyStore, + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on expired token', async () => { + return expect(async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token = await factory.issueToken({ + claims: { sub: 'foo' }, + }); + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on incorrect signing key', async () => { + const hackerFactory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: new MemoryKeyStore(), + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should accept token from new key', async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token1 = await factory.issueToken({ claims: { sub: 'foo1' } }); + try { + // This throws as token has already expired + await client.authenticate(token1); + } catch (_err) { + // Ignore thrown error + } + // Move forward in time where the signing key has been rotated + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should not be fooled by the none algorithm', async () => { + return expect(async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const header = btoa( + JSON.stringify({ alg: 'none', kid: jwtKid(token) }), + ); + const payload = btoa( + JSON.stringify({ + iss: mockBaseUrl, + sub: 'foo', + aud: 'backstage', + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 60000, + }), + ); + const fakeToken = `${header}.${payload}.`; + return await client.authenticate(fakeToken); + }).rejects.toThrow(); + }); + }); + + describe('getBearerToken', () => { + it('should return undefined on undefined input', async () => { + const token = IdentityClient.getBearerToken(undefined); + expect(token).toBeUndefined(); + }); + + it('should return undefined on malformed input', async () => { + const token = IdentityClient.getBearerToken('malformed'); + expect(token).toBeUndefined(); + }); + + it('should return undefined on unexpected scheme', async () => { + const token = IdentityClient.getBearerToken('Basic token'); + expect(token).toBeUndefined(); + }); + + it('should return Bearer token', async () => { + const token = IdentityClient.getBearerToken('Bearer token'); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite extra space', async () => { + const token = IdentityClient.getBearerToken('Bearer \n token '); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite unconventionial case', async () => { + const token = IdentityClient.getBearerToken('bEARER token'); + expect(token).toEqual('token'); + }); + }); + + describe('listPublicKeys', () => { + const defaultServiceResponse: { + keys: JSONWebKey[]; + } = { + keys: [ + { + crv: 'P-256', + x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls', + y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw', + kty: 'EC', + kid: 'kid-a', + alg: 'ES256', + use: 'sig', + }, + ], + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => { + return res(ctx.json(defaultServiceResponse)); + }), + ); + }); + + it('should use the correct endpoint', async () => { + const response = await client.listPublicKeys(); + expect(response).toEqual(defaultServiceResponse); + }); + }); +}); diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts new file mode 100644 index 0000000000..970c30dd40 --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; +import { BackstageIdentity } from '../providers'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +const CLOCK_MARGIN_S = 10; + +/** + * A identity client to interact with auth-backend + * and authenticate backstage identity tokens + * + * @experimental This is not a stable API yet + */ +export class IdentityClient { + private readonly discovery: PluginEndpointDiscovery; + private readonly issuer: string; + private keyStore: JWKS.KeyStore; + private keyStoreUpdated: number; + + constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) { + this.discovery = options.discovery; + this.issuer = options.issuer; + this.keyStore = new JWKS.KeyStore(); + this.keyStoreUpdated = 0; + } + + /** + * Verifies the given backstage identity token + * Returns a BackstageIdentity (user) matching the token. + * The method throws an error if verification fails. + */ + async authenticate(token: string | undefined): Promise { + // Extract token from header + if (!token) { + throw new Error('No token specified'); + } + // Get signing key matching token + const key = await this.getKey(token); + if (!key) { + throw new Error('No signing key matching token found'); + } + // Verify token claims and signature + // Note: Claims must match those set by TokenFactory when issuing tokens + // Note: verify throws if verification fails + const decoded = JWT.IdToken.verify(token, key, { + algorithms: ['ES256'], + audience: 'backstage', + issuer: this.issuer, + }) as { sub: string }; + // Verified, return the matching user as BackstageIdentity + // TODO: Settle internal user format/properties + const user: BackstageIdentity = { + id: decoded.sub, + idToken: token, + }; + return user; + } + + /** + * Parses the given authorization header and returns + * the bearer token, or null if no bearer token is given + */ + static getBearerToken( + authorizationHeader: string | undefined, + ): string | undefined { + if (typeof authorizationHeader !== 'string') { + return undefined; + } + const matches = authorizationHeader.match(/Bearer\s+(\S+)/i); + return matches?.[1]; + } + + /** + * Returns the public signing key matching the given jwt token, + * or null if no matching key was found + */ + private async getKey(rawJwtToken: string): Promise { + const { header, payload } = JWT.decode(rawJwtToken, { + complete: true, + }) as { + header: { kid: string }; + payload: { iat: number }; + }; + + // Refresh public keys if needed + // Add a small margin in case clocks are out of sync + const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid }); + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S; + if (!keyStoreHasKey && issuedAfterLastRefresh) { + await this.refreshKeyStore(); + } + + return this.keyStore.get({ kid: header.kid }); + } + + /** + * Lists public part of keys used to sign Backstage Identity tokens + */ + async listPublicKeys(): Promise<{ + keys: JSONWebKey[]; + }> { + const url = `${await this.discovery.getBaseUrl( + 'auth', + )}/.well-known/jwks.json`; + const response = await fetch(url); + + if (!response.ok) { + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + const publicKeys: { keys: JSONWebKey[] } = await response.json(); + + return publicKeys; + } + + /** + * Fetches public keys and caches them locally + */ + private async refreshKeyStore(): Promise { + const now = Date.now() / 1000; + const publicKeys = await this.listPublicKeys(); + this.keyStore = JWKS.asKeyStore({ + keys: publicKeys.keys.map(key => key as JSONWebKey), + }); + this.keyStoreUpdated = now; + } +} diff --git a/plugins/auth-backend/src/identity/MemoryKeyStore.ts b/plugins/auth-backend/src/identity/MemoryKeyStore.ts new file mode 100644 index 0000000000..b35002b146 --- /dev/null +++ b/plugins/auth-backend/src/identity/MemoryKeyStore.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { utc } from 'moment'; +import { KeyStore, AnyJWK, StoredKey } from './types'; + +export class MemoryKeyStore implements KeyStore { + private readonly keys = new Map< + string, + { createdAt: moment.Moment; key: string } + >(); + + async addKey(key: AnyJWK): Promise { + this.keys.set(key.kid, { + createdAt: utc(), + key: JSON.stringify(key), + }); + } + + async removeKeys(kids: string[]): Promise { + for (const kid of kids) { + this.keys.delete(kid); + } + } + + async listKeys(): Promise<{ items: StoredKey[] }> { + return { + items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ + createdAt, + key: JSON.parse(keyStr), + })), + }; + } +} diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 8b719799b1..0c7febe616 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -14,43 +14,13 @@ * limitations under the License. */ -import { utc } from 'moment'; +import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; import { getVoidLogger } from '@backstage/backend-common'; -import { KeyStore, AnyJWK, StoredKey } from './types'; import { JWKS, JSONWebKey, JWT } from 'jose'; const logger = getVoidLogger(); -class MemoryKeyStore implements KeyStore { - private readonly keys = new Map< - string, - { createdAt: moment.Moment; key: string } - >(); - - async addKey(key: AnyJWK): Promise { - this.keys.set(key.kid, { - createdAt: utc(), - key: JSON.stringify(key), - }); - } - - async removeKeys(kids: string[]): Promise { - for (const kid of kids) { - this.keys.delete(kid); - } - } - - async listKeys(): Promise<{ items: StoredKey[] }> { - return { - items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ - createdAt, - key: JSON.parse(keyStr), - })), - }; - } -} - function jwtKid(jwt: string): string { const { header } = JWT.decode(jwt, { complete: true }) as { header: { kid: string }; diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 19eec94556..a76dcea440 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,6 +15,7 @@ */ export { createOidcRouter } from './router'; +export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export type { KeyStore, TokenIssuer, TokenParams } from './types'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 3d3f059dcc..a352362089 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -15,6 +15,7 @@ */ export * from './service/router'; +export { IdentityClient } from './identity'; export * from './providers'; // flow package provides 2 functions diff --git a/plugins/auth-backend/src/providers/aws-alb/index.ts b/plugins/auth-backend/src/providers/aws-alb/index.ts new file mode 100644 index 0000000000..f8b5c9e5d7 --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { createAwsAlbProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts new file mode 100644 index 0000000000..bae809971e --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import { JWT } from 'jose'; + +import { AwsAlbAuthProvider } from './provider'; +import { AuthResponse } from '../types'; + +const jwtMock = JWT as jest.Mocked; + +const mockKey = async () => { + return `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I +yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== +-----END PUBLIC KEY----- +`; +}; + +jest.mock('jose'); + +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + text: async () => { + return mockKey(); + }, + }; + }, +})); + +const identityResolutionCallbackMock = async (): Promise> => { + return { + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }; +}; + +const identityResolutionCallbackRejectedMock = async (): Promise< + AuthResponse +> => { + throw new Error('failed'); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AwsALBAuthProvider', () => { + const catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const mockResponseSend = jest.fn(); + const mockRequest = ({ + header: jest.fn(() => { + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzc3VlciI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.zUkMYAuMwC1T0tyHMpxXrkbFDa4aGhB8d9um_tI2hsI'; + }), + } as unknown) as express.Request; + const mockRequestWithoutJwt = ({ + header: jest.fn(() => { + return undefined; + }), + } as unknown) as express.Request; + const mockResponse = ({ + header: () => jest.fn(), + send: mockResponseSend, + } as unknown) as express.Response; + + describe('should transform to type OAuthResponse', () => { + it('when JWT is valid and identity is resolved successfully', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + jwtMock.verify.mockImplementationOnce(() => ({ + sub: 'foo', + })); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual({ + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }); + }); + }); + describe('should fail when', () => { + it('JWT is missing', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + await provider.refresh(mockRequestWithoutJwt, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('JWT is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + jwtMock.verify.mockImplementationOnce(() => { + throw new Error('bad JWT'); + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('issuer is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foobar', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('identity resolution callback rejects', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackRejectedMock, + issuer: 'foo', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts new file mode 100644 index 0000000000..41dc5cf916 --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -0,0 +1,130 @@ +/* + * 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.issuer !== this.options.issuer + ) { + throw new Error('issuer mismatch on JWT'); + } + + const resolvedEntity = await this.options.identityResolutionCallback( + payload, + this.catalogClient, + ); + res.send(resolvedEntity); + } catch (e) { + this.logger.error('exception occurred during JWT processing', e); + res.send(401); + } + } else { + res.send(401); + } + } + + start(): Promise { + return Promise.resolve(undefined); + } + + async getKey(keyId: string): Promise { + const optionalCacheKey = this.keyCache.get(keyId); + if (optionalCacheKey) { + return 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); + return keyValue; + } +} + +export const createAwsAlbProvider = ({ + logger, + catalogApi, + config, + identityResolver, +}: AuthProviderFactoryOptions) => { + const region = config.getString('region'); + const issuer = config.getString('iss'); + if (identityResolver !== undefined) { + return new AwsAlbAuthProvider(logger, catalogApi, { + region, + issuer, + identityResolutionCallback: identityResolver, + }); + } + throw new Error( + 'Identity resolver is required to use this authentication provider', + ); +}; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 7bad4f4d81..619fb1c706 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -25,6 +25,7 @@ import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; +import { createAwsAlbProvider } from './aws-alb'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -37,4 +38,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oauth2: createOAuth2Provider, oidc: createOidcProvider, onelogin: createOneLoginProvider, + awsalb: createAwsAlbProvider, }; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index f2bd8dcb90..fcbe9d7abc 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -71,6 +71,7 @@ describe('OidcAuthProvider', () => { const jwt = { sub: 'alice', iss: 'https://oidc.test', + iat: Date.now(), aud: clientMetadata.clientId, exp: Date.now() + 10000, }; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 7b663a5528..74541d8294 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, @@ -112,30 +113,40 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } } -type SAMLProviderOptions = { - entryPoint: string; - issuer: string; - path: string; +type SAMLProviderOptions = SamlConfig & { 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`, + 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); }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index a40f2a96ee..1a4e7b118a 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -112,6 +112,19 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } +/** + * EXPERIMENTAL - this will almost certainly break in a future release. + * + * Used to resolve an identity from auth information in some auth providers. + */ +export type ExperimentalIdentityResolver = ( + /** + * An object containing information specific to the auth provider. + */ + payload: object, + catalogApi: CatalogApi, +) => Promise>; + export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -120,6 +133,7 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; + identityResolver?: ExperimentalIdentityResolver; }; export type AuthProviderFactory = ( diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 8e941457a9..77620c1cea 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,76 @@ # @backstage/plugin-catalog-backend +## 0.5.4 + +### Patch Changes + +- def2307f3: Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the + location that was registered to the catalog and which emitted this entity. It has a different + semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct + parent location that created this entity. + + Consider this example: The Backstage operator adds a location of type `github-org` in the + `app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor + discovers the entities in the following branch + `Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be: + + ```yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: xyz + annotations: + # This entity was added by the 'github-org:…' location + backstage.io/managed-by-location: github-org:… + # The entity was added because the 'bootstrap:boostrap' was added to the catalog + backstage.io/managed-by-origin-location: bootstrap:bootstrap + # ... + spec: + # ... + ``` + +- 318a6af9f: Change AWS Account type from Component to Resource +- ac7be581a: Refuse to remove the bootstrap location +- ad838c02f: Reduce log noise on locations refresh +- f9ba00a1c: Update the @azure/msal-node dependency to 1.0.0-beta.3. +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + +## 0.5.3 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- ade6b3bdf: AWS SDK version bump for Catalog Backend. +- abbee6fff: Implement System, Domain and Resource entity kinds. +- 147fadcb9: Add subcomponentOf to Component kind to represent subsystems of larger components. +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.5.2 + +### Patch Changes + +- 99be3057c: Fixed a bug where the catalog would read back all entities when adding a location that already exists. +- 49d2016a4: Change `location_update_log` columns from `nvarchar(255)` to `text` +- 73e75ea0a: Add processor for ingesting AWS accounts from AWS Organizations +- 071711d70: Remove `sqlite3` as a dependency. You may need to add `sqlite3` as a dependency of your backend if you were relying on this indirect dependency. +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + ## 0.5.1 ### Patch Changes @@ -40,7 +111,7 @@ ### Patch Changes -- c6eeefa35: Add support for Github Enterprise in GitHubOrgReaderProcessor so you can properly ingest users of a GHE organization. +- c6eeefa35: Add support for GitHub Enterprise in GitHubOrgReaderProcessor so you can properly ingest users of a GHE organization. - fb386b760: Break the refresh loop into several smaller transactions - 7c3ffc0cd: Support `profile` of groups including `displayName`, `email`, and `picture` in `LdapOrgReaderProcessor`. The source fields for them can be configured in the diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 1a43dd6d62..b2db27770b 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.1", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,14 +29,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@aws-sdk/client-organizations": "^3.2.0", + "@azure/msal-node": "^1.0.0-beta.3", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@octokit/graphql": "^4.5.6", + "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", - "aws-sdk": "^2.817.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", @@ -50,7 +50,6 @@ "morgan": "^1.10.0", "p-limit": "^3.0.2", "qs": "^6.9.4", - "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -58,7 +57,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.4.7", "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", @@ -67,6 +66,7 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.21.2", + "sqlite3": "^5.0.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 7ba6897236..76fc63c9b3 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -205,7 +205,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } } - if (options?.outputEntities) { + if (options?.outputEntities && responses.length > 0) { const writtenEntities = await this.database.entities( tx, EntityFilters.ofMatchers({ @@ -285,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 }); } } diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 130ed41eb1..b319bb832d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -137,6 +137,22 @@ describe('CommonDatabase', () => { await expect(db.location(location.id)).rejects.toThrow(/Found no location/); }); + it('refuses to remove the bootstrap location', async () => { + const input: Location = { + id: 'dd12620d-0436-422f-93bd-929aa0788123', + type: 'bootstrap', + target: 'bootstrap', + }; + + const output = await db.transaction( + async tx => await db.addLocation(tx, input), + ); + + await expect( + db.transaction(async tx => await db.removeLocation(tx, output.id)), + ).rejects.toThrow(ConflictError); + }); + describe('addEntities', () => { it('happy path: adds entities to empty database', async () => { const result = await db.transaction(tx => diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index d641cbb7b6..2edc0ca5b8 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -330,15 +330,21 @@ export class CommonDatabase implements Database { async removeLocation(txOpaque: Transaction, id: string): Promise { const tx = txOpaque as Knex.Transaction; + const locations = await tx('locations') + .where({ id }) + .select(); + if (!locations.length) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + if (locations[0].type === 'bootstrap') { + throw new ConflictError('You may not delete the bootstrap location.'); + } + await tx('entities') .where({ location_id: id }) .update({ location_id: null }); - - const result = await tx('locations').where({ id }).del(); - - if (!result) { - throw new NotFoundError(`Found no location with ID ${id}`); - } + await tx('locations').where({ id }).del(); } async location(id: string): Promise { diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index b7e9c48fe5..ba97232810 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -186,7 +186,7 @@ export class HigherOrderOperations implements HigherOrderOperation { throw e; } - this.logger.info(`Posting update success markers`); + this.logger.debug(`Posting update success markers`); await this.locationsCatalog.logUpdateSuccess( location.id, 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..cb703b8e15 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -78,13 +78,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({ @@ -165,6 +169,7 @@ export class LocationReaders implements LocationReader { private async handleEntity( item: CatalogProcessorEntityResult, emit: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { const { processors, logger } = this.options; @@ -185,6 +190,7 @@ export class LocationReaders implements LocationReader { current, item.location, emit, + originLocation, ); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts new file mode 100644 index 0000000000..cfd98e9e6a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; + +describe('AnnotateLocationEntityProcessor', () => { + describe('preProcessEntity', () => { + it('adds annotations', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }; + + const location: LocationSpec = { + type: 'url', + target: 'my-location', + }; + const originLocation: LocationSpec = { + type: 'url', + target: 'my-origin-location', + }; + + const processor = new AnnotateLocationEntityProcessor(); + + expect( + await processor.preProcessEntity( + entity, + location, + () => {}, + originLocation, + ), + ).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'backstage.io/managed-by-location': 'url:my-location', + 'backstage.io/managed-by-origin-location': 'url:my-origin-location', + }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts index ea8afcddc5..b40378226a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts @@ -14,20 +14,28 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + LOCATION_ANNOTATION, + LocationSpec, + ORIGIN_LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; import lodash from 'lodash'; -import { CatalogProcessor } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; export class AnnotateLocationEntityProcessor implements CatalogProcessor { async preProcessEntity( entity: Entity, location: LocationSpec, + _: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { return lodash.merge( { metadata: { annotations: { - 'backstage.io/managed-by-location': `${location.type}:${location.target}`, + [LOCATION_ANNOTATION]: `${location.type}:${location.target}`, + [ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`, }, }, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index eb5e634d66..6eb7a45c63 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -27,29 +27,25 @@ describe('AwsOrganizationCloudAccountProcessor', () => { afterEach(() => jest.resetAllMocks()); it('generates component entities for accounts', async () => { - listAccounts.mockImplementation(() => { - return { - async promise() { - return { - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - ], - NextToken: undefined, - }; - }, - }; - }); + listAccounts.mockImplementation(() => + Promise.resolve({ + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + ], + NextToken: undefined, + }), + ); await processor.readLocation(location, false, emit); expect(emit).toBeCalledWith({ type: 'entity', location, entity: { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { 'amazonaws.com/arn': @@ -62,7 +58,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }, @@ -71,27 +66,23 @@ describe('AwsOrganizationCloudAccountProcessor', () => { it('filters out accounts not in specified location target', async () => { const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; - listAccounts.mockImplementation(() => { - return { - async promise() { - return { - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - { - Arn: - 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', - Name: 'testaccount2', - }, - ], - NextToken: undefined, - }; - }, - }; - }); + listAccounts.mockImplementation(() => + Promise.resolve({ + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + { + Arn: + 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', + Name: 'testaccount2', + }, + ], + NextToken: undefined, + }), + ); await processor.readLocation(location, false, emit); expect(emit).toBeCalledTimes(1); expect(emit).toBeCalledWith({ @@ -99,7 +90,7 @@ describe('AwsOrganizationCloudAccountProcessor', () => { location, entity: { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { 'amazonaws.com/arn': @@ -112,7 +103,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts index f019f5207b..40516f88f7 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; import { - ComponentEntityV1alpha1, - LocationSpec, -} from '@backstage/catalog-model'; -import AWS, { Organizations } from 'aws-sdk'; -import { Account, ListAccountsResponse } from 'aws-sdk/clients/organizations'; + Account, + Organizations, + ListAccountsCommandOutput, +} from '@aws-sdk/client-organizations'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -38,7 +38,7 @@ const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { organizations: Organizations; constructor() { - this.organizations = new AWS.Organizations({ + this.organizations = new Organizations({ region: AWS_ORGANIZATION_REGION, }); // Only available in us-east-1 } @@ -67,9 +67,9 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { let nextToken = undefined; while (isInitialAttempt || nextToken) { isInitialAttempt = false; - const orgAccounts: ListAccountsResponse = await this.organizations - .listAccounts({ NextToken: nextToken }) - .promise(); + const orgAccounts: ListAccountsCommandOutput = await this.organizations.listAccounts( + { NextToken: nextToken }, + ); if (orgAccounts.Accounts) { awsAccounts = awsAccounts.concat(orgAccounts.Accounts); } @@ -79,13 +79,13 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { return awsAccounts; } - mapAccountToComponent(account: Account): ComponentEntityV1alpha1 { + mapAccountToComponent(account: Account): ResourceEntityV1alpha1 { const { accountId, organizationId } = this.extractInformationFromArn( account.Arn as string, ); return { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { [ACCOUNTID_ANNOTATION]: accountId, @@ -97,7 +97,6 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }; @@ -126,7 +125,7 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { } return true; }) - .forEach((entity: ComponentEntityV1alpha1) => { + .forEach((entity: ResourceEntityV1alpha1) => { emit(results.entity(location, entity)); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index 94a08c5e73..feb4791477 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -17,7 +17,10 @@ import { ApiEntity, ComponentEntity, + DomainEntity, GroupEntity, + ResourceEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -38,15 +41,17 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 'service', owner: 'o', + subcomponentOf: 's', lifecycle: 'l', providesApis: ['b'], consumesApis: ['c'], + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(6); + expect(emit).toBeCalledTimes(10); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -95,6 +100,38 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'API', namespace: 'default', name: 'c' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Component', namespace: 'default', name: 's' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); }); it('generates relations for api entities', async () => { @@ -107,12 +144,13 @@ describe('BuiltinKindsEntityProcessor', () => { owner: 'o', lifecycle: 'l', definition: 'd', + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledTimes(4); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -129,6 +167,150 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'API', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for resource entities', async () => { + const entity: ResourceEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { name: 'n' }, + spec: { + type: 'database', + owner: 'o', + system: 's', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for system entities', async () => { + const entity: SystemEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { name: 'n' }, + spec: { + owner: 'o', + domain: 'd', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'd' }, + type: 'hasPart', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Domain', namespace: 'default', name: 'd' }, + }, + }); + }); + + it('generates relations for domain entities', async () => { + const entity: DomainEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { name: 'n' }, + spec: { + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Domain', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); }); it('generates relations for user entities', async () => { diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 6cbec5d655..c75a46874d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -19,6 +19,8 @@ import { apiEntityV1alpha1Validator, ComponentEntity, componentEntityV1alpha1Validator, + DomainEntity, + domainEntityV1alpha1Validator, Entity, getEntityName, GroupEntity, @@ -31,11 +33,17 @@ import { RELATION_CHILD_OF, RELATION_CONSUMES_API, RELATION_HAS_MEMBER, + RELATION_HAS_PART, RELATION_MEMBER_OF, RELATION_OWNED_BY, RELATION_OWNER_OF, RELATION_PARENT_OF, + RELATION_PART_OF, RELATION_PROVIDES_API, + ResourceEntity, + resourceEntityV1alpha1Validator, + SystemEntity, + systemEntityV1alpha1Validator, templateEntityV1alpha1Validator, UserEntity, userEntityV1alpha1Validator, @@ -47,10 +55,13 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ apiEntityV1alpha1Validator, componentEntityV1alpha1Validator, + resourceEntityV1alpha1Validator, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, templateEntityV1alpha1Validator, userEntityV1alpha1Validator, + systemEntityV1alpha1Validator, + domainEntityV1alpha1Validator, ]; async validateEntityKind(entity: Entity): Promise { @@ -115,6 +126,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNED_BY, RELATION_OWNER_OF, ); + doEmit( + component.spec.subcomponentOf, + { defaultKind: 'Component', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); doEmit( component.spec.providesApis, { defaultKind: 'API', defaultNamespace: selfRef.namespace }, @@ -127,6 +144,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY, ); + doEmit( + component.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); } /* @@ -141,6 +164,32 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNED_BY, RELATION_OWNER_OF, ); + doEmit( + api.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); + } + + /* + * Emit relations for the Resource kind + */ + + if (entity.kind === 'Resource') { + const resource = entity as ResourceEntity; + doEmit( + resource.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + resource.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); } /* @@ -177,6 +226,40 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { ); } + /* + * Emit relations for the System kind + */ + + if (entity.kind === 'System') { + const system = entity as SystemEntity; + doEmit( + system.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + system.spec.domain, + { defaultKind: 'Domain', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); + } + + /* + * Emit relations for the Domain kind + */ + + if (entity.kind === 'Domain') { + const domain = entity as DomainEntity; + doEmit( + domain.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + return entity; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.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/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index 9bf72d620b..aac4235e3a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -27,13 +27,18 @@ import { } from './types'; describe('UrlReaderProcessor', () => { - const mockApiOrigin = 'http://localhost:23000'; + 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', @@ -57,7 +62,12 @@ describe('UrlReaderProcessor', () => { 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', diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts index bdb5918b2f..3dfc58e773 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts @@ -96,6 +96,10 @@ export class MicrosoftGraphClient { scopes: ['https://graph.microsoft.com/.default'], }); + if (!token) { + throw new Error('Error while requesting token for Microsoft Graph'); + } + return await fetch(url, { headers: { Authorization: `Bearer ${token.accessToken}`, diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 5da3510f7a..1bf30567bc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -46,12 +46,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; /** diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index ceee335db0..ad536e45dc 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-catalog-graphql +## 0.2.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + ## 0.2.5 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index fb013f53c3..4533a541f0 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.7", "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index a7301e43a3..2d75ae6af4 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,59 @@ # @backstage/plugin-catalog-import +## 0.3.5 + +### Patch Changes + +- 2b514d532: Modifying import functionality to register existing catalog-info.yaml if one exists in given GitHub repository +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [0b135e7e0] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [fa8ba330a] +- Updated dependencies [a187b8ad0] +- Updated dependencies [ed6baab66] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/integration@0.3.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.4 + +### Patch Changes + +- 34a01a171: Improve how URLs are analyzed for add/import +- bc40ccecf: Add more generic descriptions for the catalog-import form. +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- be5ac7fde: Remove dependency to `@backstage/plugin-catalog-backend`. +- Updated dependencies [466354aaa] +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/integration@0.2.0 + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + +## 0.3.3 + +### Patch Changes + +- edb7d0775: Create "url" type location when registering using the catalog-import plugin. +- Updated dependencies [99be3057c] +- Updated dependencies [49d2016a4] +- Updated dependencies [9c09a364f] +- Updated dependencies [73e75ea0a] +- Updated dependencies [036a84373] +- Updated dependencies [071711d70] + - @backstage/plugin-catalog-backend@0.5.2 + - @backstage/plugin-catalog@0.2.10 + - @backstage/integration@0.1.5 + ## 0.3.2 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index a51001d585..b2c9991bb8 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.2", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,16 +30,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.0", - "@backstage/integration": "^0.1.4", + "@backstage/catalog-model": "^0.7.0", + "@backstage/core": "^0.5.0", + "@backstage/integration": "^0.3.0", + "@backstage/plugin-catalog": "^0.2.12", "@backstage/theme": "^0.2.2", "@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", + "@octokit/rest": "^18.0.12", "git-url-parse": "^11.4.3", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -50,8 +49,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.4.7", + "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 99526a3eaa..5abb0e3e53 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -30,6 +30,11 @@ export interface CatalogImportApi { fileContent: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }>; + checkForExistingCatalogInfo(options: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }>; createRepositoryLocation(options: { location: string }): Promise; generateEntityDefinitions(options: { repo: string; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts new file mode 100644 index 0000000000..0e0cf4b323 --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogImportClient } from './CatalogImportClient'; + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => { + return { + repos: { + get: () => + Promise.resolve({ + data: { + default_branch: 'main', + }, + }), + }, + search: { + code: () => + Promise.resolve({ + data: { + total_count: 2, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }), + }, + }; + }), +})); + +describe('CatalogImportClient', () => { + describe('checkForExistingCatalogInfo', () => { + const cic = new CatalogImportClient({ + discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, + githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') }, + configApi: {} as any, + }); + it('should return the closest-to-root catalog-info from multiple responses', async () => { + const respo = await cic.checkForExistingCatalogInfo({ + owner: 'test-user', + repo: 'rest-repo', + githubIntegrationConfig: { host: 'https://github.com' }, + }); + expect(respo.exists).toBe(true); + expect(respo.url).toBe('blob/main/catalog-info.yaml'); + }); + }); +}); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 0e3ea2e1d6..d1509c2105 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -17,7 +17,6 @@ import { Octokit } from '@octokit/rest'; import { DiscoveryApi, OAuthApi, ConfigApi } from '@backstage/core'; import { CatalogImportApi } from './CatalogImportApi'; -import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend'; import { PartialEntity } from '../util/types'; import { GitHubIntegrationConfig } from '@backstage/integration'; @@ -49,7 +48,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - location: { type: 'github', target: repo }, + location: { type: 'url', target: repo }, }), }, ).catch(e => { @@ -61,8 +60,8 @@ export class CatalogImportClient implements CatalogImportApi { ); } - const payload = (await response.json()) as AnalyzeLocationResponse; - return payload.generateEntities.map(x => x.entity); + const payload = await response.json(); + return payload.generateEntities.map((x: any) => x.entity); } async createRepositoryLocation({ @@ -78,7 +77,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - type: 'github', + type: 'url', target: location, presence: 'optional', }), @@ -91,6 +90,52 @@ export class CatalogImportClient implements CatalogImportApi { } } + async checkForExistingCatalogInfo({ + owner, + repo, + githubIntegrationConfig, + }: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }> { + const token = await this.githubAuthApi.getAccessToken(['repo']); + const octo = new Octokit({ + auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, + }); + const catalogFileName = 'catalog-info.yaml'; + const query = `repo:${owner}/${repo}+filename:${catalogFileName}`; + + const searchResult = await octo.search.code({ q: query }).catch(e => { + throw new Error( + formatHttpErrorMessage( + "Couldn't search repository for metadata file.", + e, + ), + ); + }); + const exists = searchResult.data.total_count > 0; + if (exists) { + const repoInformation = await octo.repos.get({ owner, repo }).catch(e => { + throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e)); + }); + const defaultBranch = repoInformation.data.default_branch; + + // Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item', + // aka magic. + // Sorting to use the shortest item, closest to the repository root. + const catalogInfoItem = searchResult.data.items + .map(it => it.path) + .sort((a, b) => a.length - b.length)[0]; + return { + url: `blob/${defaultBranch}/${catalogInfoItem}`, + exists, + }; + } + return { exists }; + } + async submitPrToRepo({ owner, repo, diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index ab5c71c7d3..96b1bf2298 100644 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -33,13 +33,13 @@ import { 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'; +import { urlType } from '../util/urls'; const getEntityCatalogPath = ({ entity, @@ -81,7 +81,7 @@ const ComponentConfigDisplay = ({ const onNext = useCallback(async () => { try { setSubmitting(true); - if (!parseGitUri(configFile.location).filepathtype) { + if (urlType(configFile.location) === 'tree') { const result = await submitPrToRepo(configFile); savePRLink(result.link); setSubmitting(false); @@ -100,7 +100,7 @@ const ComponentConfigDisplay = ({ return ( - {!parseGitUri(configFile.location).filepathtype ? ( + {urlType(configFile.location) === 'tree' ? ( Following config object will be submitted in a pull request to the repository{' '} @@ -127,7 +127,7 @@ const ComponentConfigDisplay = ({ )} - {!parseGitUri(configFile.location).filepathtype ? ( + {urlType(configFile.location) === 'tree' ? (
{YAML.stringify(configFile.config)}
) : ( diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx new file mode 100644 index 0000000000..91a5208bee --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentForm.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 { renderInTestApp } from '@backstage/test-utils'; +import { RegisterComponentForm } from './ImportComponentForm'; +import { + ApiProvider, + ApiRegistry, + DiscoveryApi, + errorApiRef, +} from '@backstage/core'; +import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { fireEvent, waitFor, screen } from '@testing-library/react'; + +describe('', () => { + let apis: ApiRegistry; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + apis = ApiRegistry.from([ + [catalogApiRef, new CatalogClient({ discoveryApi: {} as DiscoveryApi })], + [ + catalogImportApiRef, + new CatalogImportClient({ + discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, + githubAuthApi: { + getAccessToken: (_, __) => Promise.resolve('token'), + }, + configApi: {} as any, + }), + ], + [errorApiRef, mockErrorApi], + ]); + }); + + async function renderSUT( + nextStep: () => void = () => {}, + saveConfig: () => void = () => {}, + ) { + return await renderInTestApp( + + + , + ); + } + + it('Renders without exploding', async () => { + await renderSUT(); + expect( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + ).toBeInTheDocument(); + }); + + it('Should have basic URL validation for input', async () => { + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: 'not a url' } }, + ); + }); + await waitFor(() => { + fireEvent.click(screen.getByText('Next')); + }); + expect(screen.getByText('Must start with https://.')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index dab5ccbea2..c233be5a8c 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -26,11 +26,11 @@ 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'; +import { urlType } from '../util/urls'; const useStyles = makeStyles(theme => ({ form: { @@ -46,9 +46,14 @@ const useStyles = makeStyles(theme => ({ type Props = { nextStep: () => void; saveConfig: (configFile: ConfigSpec) => void; + repository: string; }; -export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { +export const RegisterComponentForm = ({ + nextStep, + saveConfig, + repository, +}: Props) => { const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); @@ -59,27 +64,45 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { const isMounted = useMountedState(); const errorApi = useApi(errorApiRef); - const { generateEntityDefinitions } = useGithubRepos(); + const { + generateEntityDefinitions, + checkForExistingCatalogInfo, + } = useGithubRepos(); const onSubmit = async (formData: Record) => { const { componentLocation: target } = formData; - try { - if (!isMounted()) return; - const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; + async function saveCatalogFileConfig(target: string) { + const data = await catalogApi.addLocation({ target }); + saveConfig({ + type: 'file', + location: data.location.target, + config: data.entities, + }); + } - if (type === 'repo') { + async function trySaveRepositoryConfig(target: string) { + const existingCatalog = await checkForExistingCatalogInfo(target); + if (existingCatalog.exists) { + const targetUrl = target.endsWith('/') + ? `${target}${existingCatalog.url}` + : `${target}/${existingCatalog.url}`; + await saveCatalogFileConfig(targetUrl); + } else { saveConfig({ - type, + type: 'tree', location: target, config: await generateEntityDefinitions(target), }); + } + } + + try { + if (!isMounted()) return; + const type = urlType(target); + if (type === 'tree') { + await trySaveRepositoryConfig(target); } else { - const data = await catalogApi.addLocation({ target }); - saveConfig({ - type, - location: data.location.target, - config: data.entities, - }); + await saveCatalogFileConfig(target); } nextStep(); } catch (e) { @@ -103,7 +126,7 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { name="componentLocation" required margin="normal" - helperText="Enter the full path to the repository in GitHub to start tracking your component." + helperText={`Enter the full path to the repository in ${repository} to start tracking your component.`} inputRef={register({ required: true, validate: ComponentIdValidators, diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx new file mode 100644 index 0000000000..932f9aafa0 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -0,0 +1,267 @@ +/* + * 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 { msw, renderInTestApp } from '@backstage/test-utils'; +import { ImportComponentPage } from './ImportComponentPage'; +import { + ApiProvider, + ApiRegistry, + configApiRef, + errorApiRef, +} from '@backstage/core'; +import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; + +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +let codeSearchMockResponse: () => Promise<{ + data: { + total_count: number; + items: Array<{ path: string }>; + }; +}>; + +let findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', +}); + +jest.mock('@backstage/integration', () => ({ + readGitHubIntegrationConfigs: () => ({ + find: findGithubConfigMockResponse, + }), +})); + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => { + return { + repos: { + get: () => + Promise.resolve({ + data: { + default_branch: 'main', + }, + }), + }, + search: { + code: codeSearchMockResponse, + }, + }; + }), +})); + +const OUR_GITHUB_TEST_REPO = 'https://github.com/someuser/somerepo'; +describe('', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + + beforeEach(() => { + server.use( + rest.post('https://backend.localhost/locations', (_, res, ctx) => { + return res( + ctx.status(201), + ctx.json(require('../mocks/locations-POST-response.json')), + ); + }), + rest.post('https://backend.localhost/analyze-location', (_, res, ctx) => { + return res( + ctx.json(require('../mocks/analyze-location-POST-response.json')), + ); + }), + ); + }); + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + let apis: ApiRegistry; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + const getBaseUrl = () => Promise.resolve('https://backend.localhost'); + apis = ApiRegistry.from([ + [ + catalogApiRef, + new CatalogClient({ + discoveryApi: { getBaseUrl }, + }), + ], + [ + catalogImportApiRef, + new CatalogImportClient({ + discoveryApi: { getBaseUrl }, + githubAuthApi: { + getAccessToken: (_, __) => Promise.resolve('token'), + }, + configApi: {} as any, + }), + ], + [ + configApiRef, + { + getOptional: () => 'Title', + getOptionalConfigArray: () => [], + has: () => true, + getConfig: () => ({ + has: () => true, + }), + }, + ], + [errorApiRef, mockErrorApi], + ]); + }); + + async function renderSUT() { + return await renderInTestApp( + + + , + ); + } + + it('Should not explode on non-Github URLs', async () => { + findGithubConfigMockResponse = () => undefined!!; + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { + target: { + value: 'https://test-git-provider.localhost/someuser/somerepo', + }, + }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + const firstStepInput = screen.queryByPlaceholderText( + 'https://github.com/backstage/backstage', + ); + expect(firstStepInput).not.toBeInTheDocument(); + }); + }); + + it('Should offer direct file import from non-Github URLs', async () => { + findGithubConfigMockResponse = () => undefined!!; + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { + target: { + value: + 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', + }, + }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + const firstStepInput = screen.queryByPlaceholderText( + 'https://github.com/backstage/backstage', + ); + expect(firstStepInput).not.toBeInTheDocument(); + }); + expect( + screen.getByText( + 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', + ), + ).toBeInTheDocument(); + }); + + it('Should use found yaml file directly and not create a pull request if GitHub api returns one', async () => { + findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', + }); + codeSearchMockResponse = () => + Promise.resolve({ + data: { + total_count: 3, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }); + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: OUR_GITHUB_TEST_REPO } }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect( + screen.getByText( + 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', + ), + ).toBeInTheDocument(); + + const pullReqText = screen.queryByText('pull request'); + expect(pullReqText).not.toBeInTheDocument(); + }); + }); + + it('Should indicate a pull request creation when no yaml file found in the repo', async () => { + findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', + }); + codeSearchMockResponse = () => + Promise.resolve({ + data: { + total_count: 0, + items: [], + }, + }); + const { container } = await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: OUR_GITHUB_TEST_REPO } }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.getByText(OUR_GITHUB_TEST_REPO)).toBeInTheDocument(); + }); + const textNode = container + .querySelector('a[href="https://github.com/someuser/somerepo"]') + ?.closest('p'); + expect(textNode?.innerHTML).toContain( + 'Following config object will be submitted in a pull request to the repository', + ); + expect( + screen.queryByText( + 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index 9fdb465dd1..e284e95456 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -24,6 +24,9 @@ import { SupportButton, ContentHeader, RouteRef, + useApi, + configApiRef, + ConfigApi, } from '@backstage/core'; import { RegisterComponentForm } from './ImportComponentForm'; import ImportStepper from './ImportStepper'; @@ -32,11 +35,33 @@ import { ImportFinished } from './ImportFinished'; import { PartialEntity } from '../util/types'; export type ConfigSpec = { - type: 'repo' | 'file'; + type: 'tree' | 'file'; location: string; config: PartialEntity[]; }; +function manifestGenerationAvailable(configApi: ConfigApi): boolean { + return configApi.has('integrations.github'); +} + +function repositories(configApi: ConfigApi): string[] { + const integrations = configApi.getConfig('integrations'); + const repositories = []; + if (integrations.has('github')) { + repositories.push('GitHub'); + } + if (integrations.has('bitbucket')) { + repositories.push('Bitbucket'); + } + if (integrations.has('gitlab')) { + repositories.push('GitLab'); + } + if (integrations.has('azure')) { + repositories.push('Azure'); + } + return repositories; +} + export const ImportComponentPage = ({ catalogRouteRef, }: { @@ -44,7 +69,7 @@ export const ImportComponentPage = ({ }) => { const [activeStep, setActiveStep] = useState(0); const [configFile, setConfigFile] = useState({ - type: 'repo', + type: 'tree', location: '', config: [], }); @@ -53,13 +78,17 @@ export const ImportComponentPage = ({ setActiveStep(step => (options?.reset ? 0 : step + 1)); }; + const configApi = useApi(configApiRef); + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const repos = repositories(configApi); + const repositoryString = repos.join(', ').replace(/, (\w*)$/, ' or $1'); return (
- + - Start tracking your component in Backstage by adding it to the + Start tracking your component in {appTitle} by adding it to the software catalog. @@ -73,21 +102,29 @@ export const ImportComponentPage = ({ }} > - There are two ways to register an existing component. - - GitHub Repo - - If you already have code in a GitHub repository, enter the full - URL to your repo and a new pull request with a sample Backstage - metadata Entity File (catalog-info.yaml) will be - opened for you. + Ways to register an existing component + + {manifestGenerationAvailable(configApi) && ( + + GitHub Repo + + If you already have code in a GitHub repository without + Backstage metadata file set up for it, enter the full URL to + your repo and a new pull request with a sample Backstage + metadata Entity File (catalog-info.yaml) will + be opened for you. + + + )} - GitHub Repo & Entity File + {repos.length === 1 ? `${repos[0]} ` : ''} Repository & + Entity File - If you've already created a Backstage metadata file and put it - in your repo, you can enter the full URL to that Entity File. + If you've already created a {appTitle} metadata file and put it + in your {repositoryString} repository, you can enter the full + URL to that Entity File. @@ -96,11 +133,14 @@ export const ImportComponentPage = ({ ), }, diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx index caba678999..9b0f8575a6 100644 --- a/plugins/catalog-import/src/components/ImportFinished.tsx +++ b/plugins/catalog-import/src/components/ImportFinished.tsx @@ -19,7 +19,7 @@ import { Alert } from '@material-ui/lab'; import { Button, Grid, Link } from '@material-ui/core'; type Props = { - type: 'repo' | 'file'; + type: 'tree' | 'file'; nextStep: (options?: { reset: boolean }) => void; PRLink: string; }; @@ -29,13 +29,13 @@ export const ImportFinished = ({ nextStep, PRLink, type }: Props) => { - {type === 'repo' + {type === 'tree' ? 'Pull requests have been successfully opened. You can start again to import more repositories' : 'Entity added to catalog successfully'} - {type === 'repo' ? ( + {type === 'tree' ? ( { + const getGithubIntegrationConfig = (location: string) => { const { name: repoName, owner: ownerName, resource: hostname, - } = parseGitUri(selectedRepo.location); + } = parseGitUrl(location); const configs = readGitHubIntegrationConfigs( config.getOptionalConfigArray('integrations.github') ?? [], @@ -41,9 +44,22 @@ export function useGithubRepos() { const githubIntegrationConfig = configs.find(v => v.host === hostname); if (!githubIntegrationConfig) { throw new Error( - `Unable to locate github-integration for repo-location: ${selectedRepo.location}`, + `Unable to locate github-integration for repo-location, ${location}`, ); } + return { + repoName, + ownerName, + githubIntegrationConfig, + }; + }; + + const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const { + repoName, + ownerName, + githubIntegrationConfig, + } = getGithubIntegrationConfig(selectedRepo.location); const submitPRResponse = await api .submitPrToRepo({ owner: ownerName, @@ -54,7 +70,7 @@ export function useGithubRepos() { githubIntegrationConfig, }) .catch(e => { - throw new Error(`Failed to submit PR to repo:\n${e.message}`); + throw new Error(`Failed to submit PR to repo, ${e.message}`); }); await api @@ -62,14 +78,41 @@ export function useGithubRepos() { location: submitPRResponse.location, }) .catch(e => { - throw new Error(`Failed to create repository location:\n${e.message}`); + throw new Error(`Failed to create repository location, ${e.message}`); }); return submitPRResponse; }; + const checkForExistingCatalogInfo = async ( + location: string, + ): Promise<{ exists: boolean; url?: string }> => { + let githubConfig: { + repoName: string; + ownerName: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }; + try { + githubConfig = getGithubIntegrationConfig(location); + } catch (e) { + return { exists: false }; + } + return await api + .checkForExistingCatalogInfo({ + owner: githubConfig.ownerName, + repo: githubConfig.repoName, + githubIntegrationConfig: githubConfig.githubIntegrationConfig, + }) + .catch(e => { + throw new Error( + `Failed to inspect repository for existing catalog-info.yaml, ${e.message}`, + ); + }); + }; + return { submitPrToRepo, + checkForExistingCatalogInfo, generateEntityDefinitions: (repo: string) => api.generateEntityDefinitions({ repo }), addLocation: (location: string) => diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index ce5b978e7d..efacf6fbad 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,59 @@ # @backstage/plugin-catalog +## 0.2.13 + +### Patch Changes + +- a4e636c8f: Hide the kind of owners in the about card if it's the default kind (group) +- 099c5cf4f: Show the parent component in the about card (via partOf relationship) +- a08db734c: Remove the `WelcomeBanner` that links to a plugin that is not longer wired into Backstage instances + +## 0.2.12 + +### Patch Changes + +- 593632f07: Derive the list of to-delete entities in the `UnregisterEntityDialog` from the `backstage.io/managed-by-origin-location` annotation. + The dialog also rejects deleting entities that are created by the `bootstrap:bootstrap` location. +- 33846acfc: Display the owner, system, and domain as links to the entity pages in the about card. + Only display fields in the about card that are applicable to the entity kind. +- f04db53d7: Display systems in catalog table and make both owner and system link to the entity pages. + The owner field is now taken from the relations of the entity instead of its spec. +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [ed6baab66] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/catalog-client@0.3.5 + +## 0.2.11 + +### Patch Changes + +- c00488983: Enable catalog table actions for all location types. + + The edit button has had support for other providers for a while and there is + no specific reason the View in GitHub cannot work for all locations. This + change also replaces the GitHub icon with the OpenInNew icon. + +- Updated dependencies [f3b064e1c] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/core@0.4.4 + +## 0.2.10 + +### Patch Changes + +- 9c09a364f: Remove the unused dependency to `@backstage/plugin-techdocs`. +- Updated dependencies [8e083f41f] +- Updated dependencies [947d3c269] + - @backstage/plugin-scaffolder@0.3.6 + ## 0.2.9 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index e8d2309a90..be1681c596 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.9", + "version": "0.2.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.4", - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/plugin-techdocs": "^0.5.2", + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.0", + "@backstage/core": "^0.5.0", + "@backstage/plugin-scaffolder": "^0.4.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -52,8 +51,8 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.4.7", + "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index b8d5d84316..b2d029c927 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -14,15 +14,16 @@ * limitations under the License. */ -import React from 'react'; -import { Grid, Typography, Chip, makeStyles } from '@material-ui/core'; -import { AboutField } from './AboutField'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, RELATION_OWNED_BY, - serializeEntityRef, + RELATION_PART_OF, } from '@backstage/catalog-model'; +import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; +import { EntityRefLink } from '../EntityRefLink'; +import { getEntityRelations } from '../getEntityRelations'; +import { AboutField } from './AboutField'; const useStyles = makeStyles({ description: { @@ -36,6 +37,25 @@ type Props = { export const AboutContent = ({ entity }: Props) => { const classes = useStyles(); + const isSystem = entity.kind.toLowerCase() === 'system'; + const isDomain = entity.kind.toLowerCase() === 'domain'; + const isResource = entity.kind.toLowerCase() === 'resource'; + const isComponent = entity.kind.toLowerCase() === 'component'; + const [partOfSystemRelation] = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', + }); + const [partOfComponentRelation] = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'component', + }, + ); + const [partOfDomainRelation] = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'domain', + }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + return ( @@ -43,32 +63,68 @@ export const AboutContent = ({ entity }: Props) => { {entity?.metadata?.description || 'No description'} - r.type === RELATION_OWNED_BY) - .map(({ target: { kind, name, namespace } }) => - // TODO(Rugvip): we want to provide some utils for this - serializeEntityRef({ - kind, - name, - namespace: - namespace === ENTITY_DEFAULT_NAMESPACE ? undefined : namespace, - }), - ) - .join(', ')} - gridSizes={{ xs: 12, sm: 6, lg: 4 }} - /> - - + + {ownedByRelations.map((t, i) => ( + + {i > 0 && ', '} + + + ))} + + {isSystem && ( + + {partOfDomainRelation && ( + + )} + + )} + {!isSystem && !isDomain && ( + + {partOfSystemRelation && ( + + )} + + )} + {isComponent && partOfComponentRelation && ( + + + + )} + {!isSystem && !isDomain && ( + + )} + {!isSystem && !isDomain && !isResource && ( + + )} { onChange={({ label }) => setSelectedTab(label)} /> -