diff --git a/.changeset/beige-queens-crash.md b/.changeset/beige-queens-crash.md deleted file mode 100644 index 6253717673..0000000000 --- a/.changeset/beige-queens-crash.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Optimized the `yarn install` step in the backend `Dockerfile`. - -To apply these changes to an existing app, make the following changes to `packages/backend/Dockerfile`: - -Replace the `RUN yarn install ...` line with the following: - -```bash -RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -``` diff --git a/.changeset/chatty-cooks-begin.md b/.changeset/chatty-cooks-begin.md new file mode 100644 index 0000000000..e32d22d532 --- /dev/null +++ b/.changeset/chatty-cooks-begin.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +--- + +Add AWS auth provider for Kubernetes diff --git a/.changeset/chilly-chefs-protect.md b/.changeset/chilly-chefs-protect.md deleted file mode 100644 index 22e2ea9b7e..0000000000 --- a/.changeset/chilly-chefs-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Update swagger-ui-react to 3.37.2 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/cost-insights-cyan-nails-film.md b/.changeset/cost-insights-cyan-nails-film.md deleted file mode 100644 index 69e21d87fe..0000000000 --- a/.changeset/cost-insights-cyan-nails-film.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Add breakdown view to the Cost Overview panel diff --git a/.changeset/cost-insights-yellow-trees-love.md b/.changeset/cost-insights-yellow-trees-love.md new file mode 100644 index 0000000000..19eea0dfe2 --- /dev/null +++ b/.changeset/cost-insights-yellow-trees-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Add support for additional breakdowns of daily cost data. +This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. diff --git a/.changeset/famous-items-travel.md b/.changeset/famous-items-travel.md deleted file mode 100644 index a0aa02732d..0000000000 --- a/.changeset/famous-items-travel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs-backend': patch ---- - -Update URL auth format for Gitlab clone diff --git a/.changeset/fast-flowers-tickle.md b/.changeset/fast-flowers-tickle.md new file mode 100644 index 0000000000..3eacb6f1a0 --- /dev/null +++ b/.changeset/fast-flowers-tickle.md @@ -0,0 +1,31 @@ +--- +'@backstage/plugin-kafka': minor +'@backstage/plugin-kafka-backend': minor +--- + +Added support for multiple Kafka clusters and multiple consumers per component. +Note that this introduces several breaking changes. + +1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + +```diff +kafka: + clientId: backstage +- brokers: +- - localhost:9092 ++ clusters: ++ - name: prod ++ brokers: ++ - localhost:9092 +``` + +2. Configuration of services has changed as well to support multiple clusters: + +```diff + annotations: +- kafka.apache.org/consumer-groups: consumer ++ kafka.apache.org/consumer-groups: prod/consumer +``` + +3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` 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/forty-jobs-occur.md b/.changeset/forty-jobs-occur.md new file mode 100644 index 0000000000..b1620a7535 --- /dev/null +++ b/.changeset/forty-jobs-occur.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add `EntityRefLinks` that shows one or multiple entity ref links. + +Change the about card and catalog table to use `EntityRefLinks` due to the +nature of relations to support multiple relations per type. diff --git a/.changeset/four-plants-happen.md b/.changeset/four-plants-happen.md deleted file mode 100644 index b273d8ec41..0000000000 --- a/.changeset/four-plants-happen.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-sentry': patch -'@backstage/plugin-welcome': patch ---- - -Refactor route registration to remove deprecating code diff --git a/.changeset/green-boats-attend.md b/.changeset/green-boats-attend.md new file mode 100644 index 0000000000..ae2324291e --- /dev/null +++ b/.changeset/green-boats-attend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Support supplying a custom catalog descriptor file parser diff --git a/.changeset/grumpy-crews-build.md b/.changeset/grumpy-crews-build.md deleted file mode 100644 index 502210c257..0000000000 --- a/.changeset/grumpy-crews-build.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Fix typo of "visibility" in config schema reference - -If you have defined a config element named `visiblity`, you -will need to fix the spelling to `visibility`. For more info, -see https://backstage.io/docs/conf/defining#visibility. 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/light-bulldogs-guess.md b/.changeset/light-bulldogs-guess.md deleted file mode 100644 index d7b5a5aef5..0000000000 --- a/.changeset/light-bulldogs-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Allow the `backend.listen.port` config to be both a number or a string. diff --git a/.changeset/light-nails-crash.md b/.changeset/light-nails-crash.md deleted file mode 100644 index c7791d1027..0000000000 --- a/.changeset/light-nails-crash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Bump versions of `esbuild` and `rollup-plugin-esbuild` diff --git a/.changeset/loud-terms-kiss.md b/.changeset/loud-terms-kiss.md new file mode 100644 index 0000000000..da50e56e3a --- /dev/null +++ b/.changeset/loud-terms-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Ask the SonarQube server for all support metrics prior to querying them for a project. 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/new-nails-thank.md b/.changeset/new-nails-thank.md deleted file mode 100644 index 4fc7ccac68..0000000000 --- a/.changeset/new-nails-thank.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-proxy-backend': patch ---- - -Filter the headers that are sent from the proxied-targed back to the frontend to not forwarded unwanted authentication or -monitoring contexts from other origins (like `Set-Cookie` with e.g. a google analytics context). The implementation reuses -the `allowedHeaders` configuration that now controls both directions `frontend->target` and `target->frontend`. diff --git a/.changeset/perfect-dryers-sell.md b/.changeset/perfect-dryers-sell.md deleted file mode 100644 index e1c5592162..0000000000 --- a/.changeset/perfect-dryers-sell.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Removed `"resolutions"` entry for `esbuild` in the root `package.json` in order to use the version specified by `@backstage/cli`. - -To apply this change to an existing app, remove the following from your root `package.json`: - -```json -"resolutions": { - "esbuild": "0.6.3" -}, -``` diff --git a/.changeset/slow-insects-fail.md b/.changeset/slow-insects-fail.md deleted file mode 100644 index a07e025f34..0000000000 --- a/.changeset/slow-insects-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Add [API docs plugin](https://github.com/backstage/backstage/tree/master/plugins/api-docs) to new apps being created through the CLI. diff --git a/.changeset/smart-turkeys-bathe.md b/.changeset/smart-turkeys-bathe.md deleted file mode 100644 index 5f429b5b37..0000000000 --- a/.changeset/smart-turkeys-bathe.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor -'@backstage/plugin-catalog-import': minor -'@backstage/catalog-model': patch -'@backstage/plugin-scaffolder': patch ---- - -Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: - -```ts -export default async function createPlugin(env: PluginEnvironment) { - const builder = new CatalogBuilder(env); - const { - entitiesCatalog, - locationsCatalog, - higherOrderOperation, - locationAnalyzer, //<-- - } = await builder.build(); - - return await createRouter({ - entitiesCatalog, - locationsCatalog, - higherOrderOperation, - locationAnalyzer, //<-- - logger: env.logger, - }); -} -``` diff --git a/.changeset/sour-eels-dream.md b/.changeset/sour-eels-dream.md deleted file mode 100644 index 75b9fb3c8d..0000000000 --- a/.changeset/sour-eels-dream.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Gracefully handle missing codeowners. - -The CodeOwnersProcessor now also takes a logger as a parameter. diff --git a/.changeset/stupid-taxis-sneeze.md b/.changeset/stupid-taxis-sneeze.md deleted file mode 100644 index d8e0fb130a..0000000000 --- a/.changeset/stupid-taxis-sneeze.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/cli': minor -'@backstage/plugin-cost-insights': patch ---- - -sort product panels and navigation menu by greatest cost -update tsconfig.json to use ES2020 api 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/tidy-actors-repair.md b/.changeset/tidy-actors-repair.md deleted file mode 100644 index 4869400556..0000000000 --- a/.changeset/tidy-actors-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Use type EntityName from catalog-model for entities diff --git a/.changeset/twenty-trees-travel.md b/.changeset/twenty-trees-travel.md deleted file mode 100644 index 7f65084320..0000000000 --- a/.changeset/twenty-trees-travel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity. diff --git a/.changeset/weak-roses-search.md b/.changeset/weak-roses-search.md deleted file mode 100644 index 9f5074ce48..0000000000 --- a/.changeset/weak-roses-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-proxy-backend': patch ---- - -Add configuration schema for the commonly used properties diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b3c9bc653d..9a5ef09554 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,9 +6,12 @@ * @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/techdocs @backstage/techdocs-core /plugins/search @backstage/techdocs-core +/plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/techdocs-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7f889392ac..425e6eea6f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,4 +1,5 @@ abc +adamdmharvey andrewthauer Apdex api @@ -7,6 +8,8 @@ apis args asciidoc async +Autoscaling +autoscaling Avro backrub Balachandran @@ -23,6 +26,7 @@ changesets Changesets chanwit Chanwit +ci cisphobia cissexist classname @@ -34,6 +38,8 @@ Codecov codehilite Codehilite codeowners +composability +composable config Config configmaps @@ -42,10 +48,13 @@ const cookiecutter css dariddler +dataflow deadnaming destructured dev +devops devs +dhenneke discoverability Discoverability dls @@ -54,32 +63,40 @@ Dockerfile Dockerize dockerode Docusaurus +Dominik +dtuite dzolotusky -eg Ek +etag env Env +esbuild eslint +Expedia facto failover Figma Firekube +Fiverr freben Fredrik -github -Github -gitlab -Gitlab +Georgoulas +gitbeaker +GitHub +GitLab Grafana +GraphQL graphql graphviz Gustavsson Hackathons haproxy -heroku +Henneke Heroku horizontalpodautoscalers Hostname +hotspots +html http https Iain @@ -88,8 +105,8 @@ incentivised inlined inlinehilite interop -javascript -Javascript +Ioannis +JavaScript jq js json @@ -98,9 +115,11 @@ Kaewkasi Knex kubectl kubernetes +Kumar learnings lerna Lerna +Luxon magiclink mailto maintainership @@ -110,6 +129,7 @@ microsite middleware minikube Minikube +misconfiguration misgendering mkdocs Mkdocs @@ -125,28 +145,33 @@ neuro newrelic nginx Niklas +nodegit nohoist nonces +noop npm +nvarchar nvm -oauth -Oauth +OAuth +octokit oidc Okta Oldsberg onboarding Onboarding pagerduty +parallelization Patrik Phoen plantuml Pomaceous postgres +postpack pre prebaked preconfigured +prepack Preprarer -Prerequisities productional Protobuf proxying @@ -168,6 +193,8 @@ rollbar Rollbar Rollup Rosaceae +routable +Routable rst rsync rugvip @@ -180,12 +207,14 @@ semlas semver Serverless Sinon -smartsymobls +Sneha Snyk +sourcemaps sparklines Spotifiers spotify Spotify +sqlite squidfunk src stefanalund @@ -198,13 +227,13 @@ talkdesk Talkdesk tasklist techdocs +Telenor templated templater Templater templaters Templaters Thauer -theres toc tolerations Tolerations @@ -213,12 +242,17 @@ toolsets tooltip tooltips touchpoints +transpiled +transpilation +Tuite ui +unmanaged untracked upvote url utils validators +varchar Voi Wealthsimple Weaveworks diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fafd3756d..92660b9ca4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,6 +11,11 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [master] + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#scanning-pull-requests + paths-ignore: + - '**/*.md' + - '**/*.yml' + - '**/*.yaml' schedule: - cron: '0 8 * * 6' @@ -36,11 +41,6 @@ jobs: # a pull request then we can checkout the head. fetch-depth: 2 - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml new file mode 100644 index 0000000000..f3fe2bbc5f --- /dev/null +++ b/.github/workflows/fossa.yml @@ -0,0 +1,44 @@ +name: FOSSA +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + # We use this to modify the generated .fossa.yml + - name: Install yq + run: sudo snap install yq + + - name: Install Fossa + run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash" + + - name: Configure Fossa + # The --option flag for fossa init does not work yet, see https://github.com/fossas/fossa-cli/issues/614 + run: | + fossa init + yq eval -i '.analyze.modules[].options.strategy = "yarn-list"' .fossa.yml + + # This deletes entries for template and example packages found within packages and plugins + # Seems like yq has a bug that causes only a subset of all matches to be deleted each run + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + + - name: Show config + run: cat .fossa.yml + + - name: Fossa Analyze + env: + # FOSSA Push-Only API Token + FOSSA_API_KEY: 9ee7e8893660832a7387dcc32377fb61 + run: fossa analyze --branch "$GITHUB_REF" diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index c103583052..ca402c48d5 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -51,10 +51,13 @@ jobs: - name: test run: yarn lerna -- run test + # credit: https://github.com/appleboy/discord-action/issues/3#issuecomment-731426861 - name: Discord notification if: ${{ failure() }} - uses: Ilshidur/action-discord@0.2.0 env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - with: - args: 'Windows master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' + run: | + $MESSAGE=@" + {\"content\": \"Windows master build failed https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}\"} + "@ + C:\msys64\usr\bin\curl.exe -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST $env:DISCORD_WEBHOOK --data $MESSAGE diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index ca63e627e9..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 @@ -70,21 +82,6 @@ jobs: bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api - # Publishes current version of packages that are not already present in the registry - - name: publish - if: matrix.node-version == '12.x' - run: yarn lerna -- publish from-package --yes - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # Tags the commit with the version in the core package if the tag doesn't exist - - uses: Klemensas/action-autotag@1.2.3 - if: matrix.node-version == '12.x' - with: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - package_root: 'packages/core' - tag_prefix: 'v' - - name: Discord notification if: ${{ failure() }} uses: Ilshidur/action-discord@0.2.0 @@ -92,3 +89,86 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: 'Master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' + + # A separate release build that is only run for commits that are the result of merging the "Version Packages" PR + # We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and + # only run the build steps that are necessary for publishing + release: + needs: build + + if: needs.build.outputs.needs_release == 'true' + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + env: + CI: 'true' + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: build type declarations + run: yarn tsc:full + + - name: build packages + run: yarn lerna -- run --ignore example-app build + + # Publishes current version of packages that are not already present in the registry + - name: publish + run: yarn lerna -- publish from-package --yes + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Creates the next available tag with format "release---[.]" + - name: Create a release tag + id: create_tag + run: node scripts/create-release-tag.js + env: + GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + + # Convert the newly created tag into a release with changelog information + - name: Create release on GitHub + run: node scripts/create-github-release.js ${{ steps.create_tag.outputs.tag_name }} 1 + env: + GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + + # Notify everyone about this great new release :D + - name: Discord notification + uses: Ilshidur/action-discord@0.2.0 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + TAG_NAME: ${{ steps.create_tag.outputs.tag_name }} + with: + args: 'A new release has been published! https://github.com/backstage/backstage/releases/tag/{{TAG_NAME}}' diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 8a2fa99ed2..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 3f59b815b3..846882d853 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,6 +55,7 @@ jobs: - name: tsc run: yarn tsc + - name: build run: yarn build @@ -62,7 +63,7 @@ jobs: - name: prepare nightly release run: yarn changeset version --snapshot nightly - # Publishes the nightly release to NPM, by using tag we make sure the release is + # Publishes the nightly release to npm, by using tag we make sure the release is # not flagged as the latest release, which means that people will not get this # version of the package unless requested explicitly - name: publish nightly release diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index a8d476f713..99daba61da 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -1,7 +1,7 @@ name: Automatically add new TechDocs Issues and PRs to the GitHub project board # Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1 # New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board. -# Caveat: New PRs created from forks will not be added since GitHub actions don't share credentials with forks. +# Caveat: New PRs created from forks will not be added since GitHub Actions don't share credentials with forks. on: issues: @@ -9,17 +9,17 @@ on: pull_request: types: [opened, reopened, labeled, edited] -env: - MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} - jobs: assign_issue_or_pr_to_project: runs-on: ubuntu-latest name: Triage + env: + MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} steps: - name: Assign new issue to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.title, 'TechDocs') || contains(github.event.issue.title, 'techdocs') || contains(github.event.issue.title, 'Techdocs') @@ -30,6 +30,7 @@ jobs: - name: Assign new issue to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' @@ -38,6 +39,7 @@ jobs: - name: Assign new PR to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.title, 'TechDocs') || contains(github.event.pull_request.title, 'techdocs') || contains(github.event.pull_request.title, 'Techdocs') @@ -48,6 +50,7 @@ jobs: - name: Assign new PR to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' diff --git a/.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 0ba3e3196b..363c092198 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,15 +1,21 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| Organization | Contact | Description of Use | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac33ca7f8..9df533bb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,9 @@ # Backstage Changelog -This is a best-effort changelog where we manually collect breaking changes. It is not an exhaustive list of all changes or even features added. +This changelog is no longer being updated and will be removed in the future, as each package now has its own changelog instead. It was a best-effort changelog where we manually collected breaking changes during the `v0.1.1-alpha.` releases. If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! -## Next Release - -> Collect changes for the next release below - ## v0.1.1-alpha.26 ### @backstage/cli diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 55269dd2a5..6990f72c3f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -18,7 +18,7 @@ Harassment includes, but is not limited to: - Offensive comments related to gender, gender identity and expression, sexual orientation, disability, mental illness, neuro(a)typicality, physical appearance, body size, race, age, regional discrimination, political or religious affiliation - Unwelcome comments regarding a person’s lifestyle choices and practices, including those related to food, health, parenting, drugs, and employment - Deliberate misgendering. This includes deadnaming or persistently using a pronoun that does not correctly reflect a person's gender identity. You must address people by the name they give you when not addressing them by their username or handle -- Physical contact and simulated physical contact (eg, textual descriptions like “_hug_” or “_backrub_”) without consent or after a request to stop +- Physical contact and simulated physical contact (e.g., textual descriptions like “_hug_” or “_backrub_”) without consent or after a request to stop - Threats of violence, both physical and psychological - Incitement of violence towards any individual, including encouraging a person to commit suicide or to engage in self-harm - Deliberate intimidation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7157d9f05..d163a42954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,19 +6,19 @@ Therefore we want to create strong community of contributors -- all working toge Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. ❤️ -Backstage is released under the Apache2.0 License, and original creations contributed to this repo are accepted under the same license. +Backstage is released under the Apache 2.0 License, and original creations contributed to this repo are accepted under the same license. -# Types of Contributions +## Types of Contributions -## Report bugs +### Report bugs No one likes bugs. Report bugs as an issue [here](https://github.com/backstage/backstage/issues/new?template=bug_template.md). -## Fix bugs or build new features +### Fix bugs or build new features Look through the GitHub issues for [bugs](https://github.com/backstage/backstage/labels/bugs), [good first issues](https://github.com/backstage/backstage/labels/good%20first%20issue) or [help wanted](https://github.com/backstage/backstage/labels/help%20wanted). -## Build a plugin +### Build a plugin The value of Backstage grows with every new plugin that gets added. Wouldn't it be fantastic if there was a plugin for every infrastructure project out there? We think so. And we would love your help. @@ -26,27 +26,27 @@ A great reference example of a plugin can be found on [our blog](https://backsta What kind of plugins should/could be created? Some inspiration from the 120+ plugins that we have developed inside Spotify can be found [here](https://backstage.io/demos), but we will keep a running list of suggestions labeled with [[plugin]](https://github.com/backstage/backstage/labels/plugin). -## Suggesting a plugin +### Suggesting a plugin If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. -## Adding Non-code Contributions +### Adding Non-code Contributions Since there is such a large landscape of possible development, build, and deployment environments, we welcome community contributions in these areas in the [`/contrib`](https://github.com/backstage/backstage/tree/master/contrib) folder of the project. This is an excellent place to put things that help out the community at large, but which may not fit within the scope of the core product to support natively. Here, you will find Helm charts, alternative Docker images, and much more. -## Write Documentation +### Write Documentation The current documentation is very limited. Help us make the `/docs` folder come alive. -## Contribute to Storybook +### Contribute to Storybook We think the best way to ensure different plugins provide a consistent experience is through a solid set of reusable UI/UX components. Backstage uses [Storybook](http://backstage.io/storybook). Either help us [create new components](https://github.com/backstage/backstage/labels/help%20wanted) or improve stories for the existing ones (look for files with `*.stories.tsx`). -## Submit Feedback +### Submit Feedback The best way to send feedback is to file [an issue](https://github.com/backstage/backstage/issues). @@ -58,47 +58,59 @@ If you are proposing a feature: - Remember that this is a volunteer-driven project, and that contributions are welcome :) -## Add your company to ADOPTERS +### Add your company to ADOPTERS Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) really helps the project. -# Get Started! +## Get Started! So...feel ready to jump in? Let's do this. 👏🏻💯 Start by reading our [Getting Started](https://backstage.io/docs/getting-started/) page. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). -# Coding Guidelines +## Coding Guidelines All code is formatted with `prettier` using the configuration in the repo. If possible we recommend configuring your editor to format automatically, but you can also use the `yarn prettier --write ` command to format files. +A consistent coding style is included via [EditorConfig](https://editorconfig.org/) with the file [`.editorconfig`](.editorconfig) at the root of the repo. Depending on your editor of choice, it will either support it out of the box or you can [download a plugin](https://editorconfig.org/#download) for the config to be applied. + If you're contributing to the backend or CLI tooling, be mindful of cross-platform support. [This](https://shapeshed.com/writing-cross-platform-node/) blog post is a good guide of what to keep in mind when writing cross-platform NodeJS. Also be sure to skim through our [ADRs](https://github.com/backstage/backstage/tree/master/docs/architecture-decisions) to see if they cover what you're working on. In particular [ADR006: Avoid React.FC and React.SFC](https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr006-avoid-react-fc.md) is one to look out for. If there are any updates in `markdown` file please make sure to run `yarn run lint:docs`. Though it is checked on `lint-staged`. It is required to install [vale](https://docs.errata.ai/vale/install) separately and make sure it is accessed by global command. -# Creating Changesets +## Creating Changesets -We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. It helps us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy. it helps when contributors include changesets with their pull requests. +We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. They help us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy, it helps when contributors include changesets with their pull requests. -## To create a changeset +### When to use a changeset? + +Any time a patch, minor, or major change aligning to [Semantic Versioning](https://semver.org) is made to any published package in `packages/` or `plugins/`, a changeset should be used. It helps to align your change to the [Backstage stability index](https://backstage.io/docs/overview/stability-index) for the package you are changing, for example, when to provide additional clarity on deprecation or impacting changes which will then be included into CHANGELOGs. + +In general, changesets are not needed for the documentation, build utilities, contributed samples in `contrib/`, or the [example `packages/app`](packages/app). + +### How to create a changeset 1. Run `yarn changeset` 2. Select which packages you want to include a changeset for -3. Select impact of change that you're introducing (minor, major or patch) +3. Select impact of change that you're introducing (patch, minor, or major) 4. Add generated changeset to Git 5. Push the commit with your changeset to the branch associated with your PR -6. Accept our gratitude for making the release process easier on the maintainer +6. Accept our gratitude for making the release process easier on the maintainers -For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in changesets repository. +For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository. -# Code of Conduct +## Merging to Master + +For those contributors who have earned write access to the repository, when a pull request is approved, in general we prefer the author of the PR to perform the merge themselves. This allows them to own accountability for the change and they likely know best how or when to address pending fixes or additional follow-ups. In this way, we all help contribute to the project's successful outcomes. + +## Code of Conduct This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. [code-of-conduct]: https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md -# Security Issues? +## Security Issues? See [SECURITY](SECURITY.md). diff --git a/OWNERS.md b/OWNERS.md index 8e5002fc0a..f1649c50b8 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -22,3 +22,6 @@ People that have made significant contributions to the project and earned write - Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) - Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) +- David Tuite - Roadie (GitHub: [dtuite](https://github.com/dtuite)) +- Adam Harvey - DXC Technology (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) +- Dominik Henneke - SDA SE (GitHub: [dhenneke](https://github.com/dhenneke)) diff --git a/README.md b/README.md index a4b21539b5..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 3b476981e4..c150ef5902 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 @@ -46,20 +50,37 @@ proxy: X-Api-Key: $env: NEW_RELIC_REST_API_KEY + '/pagerduty': + target: https://api.pagerduty.com + headers: + Authorization: + $env: PAGERDUTY_TOKEN + '/buildkite/api': target: https://api.buildkite.com/v2/ headers: Authorization: $env: BUILDKITE_TOKEN + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN + organization: name: My Company +# Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs requestUrl: http://localhost:7000/api/techdocs + storageUrl: http://localhost:7000/api/techdocs/static/docs + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' + publisher: + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. sentry: organization: my-company @@ -78,6 +99,13 @@ kubernetes: - 'config' clusters: [] +kafka: + clientId: backstage + clusters: + - name: cluster + brokers: + - localhost:9092 + integrations: github: - host: github.com @@ -110,7 +138,16 @@ integrations: catalog: rules: - - allow: [Component, API, Group, User, Template, Location] + - allow: + - Component + - API + - Resource + - Group + - User + - Template + - System + - Domain + - Location processors: githubOrg: @@ -120,7 +157,7 @@ catalog: $env: GITHUB_TOKEN #### Example for how to add your GitHub Enterprise instance using the API: # - target: https://ghe.example.net - # apiBaseUrl: https://ghe.example.net/api/v3 + # apiBaseUrl: https://ghe.example.net/api # token: # $env: GHE_TOKEN ldapOrg: @@ -155,24 +192,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: @@ -189,7 +248,16 @@ 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 # session: # secret: custom session secret @@ -290,6 +358,9 @@ costInsights: bigQuery: name: BigQuery icon: search + events: + name: Events + icon: data metrics: DAU: name: Daily Active Users @@ -306,3 +377,5 @@ homepage: timezone: 'Europe/Stockholm' - label: TYO timezone: 'Asia/Tokyo' +pagerduty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' diff --git a/catalog-info.yaml b/catalog-info.yaml index 617d01093e..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/contrib/chart/backstage/templates/backend-secret.yaml b/contrib/chart/backstage/templates/backend-secret.yaml index b340f39d7c..299d893ec4 100644 --- a/contrib/chart/backstage/templates/backend-secret.yaml +++ b/contrib/chart/backstage/templates/backend-secret.yaml @@ -20,4 +20,5 @@ stringData: AZURE_TOKEN: {{ .Values.auth.azure.api.token }} NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }} TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }} + PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }} {{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index a4a0fadcc2..261f352f93 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -250,3 +250,4 @@ auth: gitlabToken: g newRelicRestApiKey: r travisciAuthToken: fake-travis-ci-auth-token + pagerdutyToken: h diff --git a/Dockerfile b/contrib/docker/frontend-with-nginx/Dockerfile similarity index 90% rename from Dockerfile rename to contrib/docker/frontend-with-nginx/Dockerfile index 174548a90c..a444c9de83 100644 --- a/Dockerfile +++ b/contrib/docker/frontend-with-nginx/Dockerfile @@ -8,8 +8,6 @@ FROM nginx:mainline # This dockerfile requires the app to be built on the host first, as it # simply copies in the build output into the image. -# The safest way to build this image is to use `yarn docker-build:app` - RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* COPY packages/app/dist /usr/share/nginx/html diff --git a/docker/default.conf.template b/contrib/docker/frontend-with-nginx/docker/default.conf.template similarity index 100% rename from docker/default.conf.template rename to contrib/docker/frontend-with-nginx/docker/default.conf.template diff --git a/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/run.sh similarity index 100% rename from docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/run.sh diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md new file mode 100644 index 0000000000..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 5d633544e3..77b820d921 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -3,7 +3,7 @@ ExampleComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Typography, Grid } from '@material-ui/core'; import { InfoCard, @@ -14,11 +14,11 @@ import { HeaderLabel, SupportButton, identityApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import ExampleFetchComponent from '../ExampleFetchComponent'; -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { const identityApi = useApi(identityApiRef); const userId = identityApi.getUserId(); const profile = identityApi.getProfile(); diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 08d14e8c4c..6992d05866 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -3,7 +3,7 @@ ExampleFetchComponent.tsx reference ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -11,8 +11,8 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; const query = `{ @@ -57,7 +57,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, @@ -76,7 +76,7 @@ export const DenseTable: FC = ({ viewer }) => { ); }; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { const auth = useApi(githubAuthApiRef); const { value, loading, error } = useAsync(async (): Promise => { diff --git a/docs/FAQ.md b/docs/FAQ.md index 9c11b015ea..da10f9724c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,6 +117,62 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. +### How do I keep my Backstage app up to date? + +In many ways one can view Backstage as a library rather than an application or +service. The `@backstage/create-app` tool that is used to create your own +Backstage app is similar to +[`create-react-app`](https://github.com/facebook/create-react-app) in that it +gives you a starting point. The code you get is meant to be evolved, and most of +the functionality you get out of the box is brought in via npm dependencies. +Keeping your app up to date generally means keeping your dependencies up to +date. The Backstage CLI provides a command to help you with that. Simply run +`yarn backstage-cli versions:bump` at the root of your repo, and the latest +versions of all Backstage packages will be installed. + +While staying up to date with new releases and changes will keep your app up to +date, it can often be convenient to use the changes done to the +`@backstage/create-app` template as another method to stay up to date. For that +purpose, any changes done to the template are documented along with upgrade +instructions in the +[changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md) +of the `@backstage/create-app` package. + +### Why can't I dynamically install plugins without modifications the app? + +This decision is part of the core architecture and development flow of +Backstage. Plugins have a lot of freedom in what they provide and how they are +integrated into the app, and it would therefore add a lot of complexity to allow +plugins to be integrated via configuration the same way as they can be +integrated with code. + +By bundling all plugins and their dependencies into one app bundle it is also +possible to do significant optimizations to the app load time by allowing +plugins to share dependencies between each other when possible. This contributes +to Backstage being fast, which is an important part of the user and developer +experience. + +### Why are there no published Docker images or helm charts for Backstage? + +As mentioned above, Backstage is not a packaged service that you can use out of +the box. In order to get started with Backstage you need to use the +`@backstage/create-app` package to create and customize your own Backstage app. + +In order to build a Docker image from your own app, you can use the +`yarn build-image` command which is included out of the box in the app template. +By default this image will bundle up both the frontend and the backend into a +single image that you can deploy using your favorite tooling. + +There are also some examples that can help you deploy Backstage to kubernetes in +the +[contrib](https://github.com/backstage/backstage/tree/master/contrib/kubernetes) +folder. + +It is possible that example images will be provided in the future, which can be +used to quickly try out a small subset of the functionality of Backstage, but +these would not be able to provide much more functionality on top of what you +can see on a demo site. + ### Do I have to write plugins in TypeScript? No, you can use JavaScript if you prefer. We want to keep the Backstage core diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 2547db5c5c..ff322c136f 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -33,10 +33,10 @@ hook exported by `@backstage/core`, or the `withApis` HOC if you prefer class components. For example, the `ErrorApi` can be accessed like this: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useApi, errorApiRef } from '@backstage/core'; -export const MyComponent: FC<{}> = () => { +export const MyComponent = () => { const errorApi = useApi(errorApiRef); // Signal to the app that something went wrong, and display the error to the user. diff --git a/docs/architecture-decisions/adr000-template.md b/docs/architecture-decisions/adr000-template.md new file mode 100644 index 0000000000..5e6edd8dba --- /dev/null +++ b/docs/architecture-decisions/adr000-template.md @@ -0,0 +1,22 @@ +--- +id: adrs-adr000 +title: ADR000: [TITLE] +description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] +--- + + + +## Context + + + +## Decision + + + +## Consequences + + + + diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md index 16783367ab..872b3311a5 100644 --- a/docs/architecture-decisions/adr001-add-adr-log.md +++ b/docs/architecture-decisions/adr001-add-adr-log.md @@ -4,12 +4,16 @@ title: ADR001: Architecture Decision Record (ADR) log description: Architecture Decision Record (ADR) logs as a reference point for the team --- -| Created | Status | -| ---------- | ------ | -| 2020-04-26 | Open | +## Decision -## Decision: A decision was made to store ADRs in a log in the project repository +A decision was made to store ADRs in a log in the project repository -## Discussion: There is a need to store big decisions made in a log as a reference point for the team, help with onboarding new members and give context to others interested in the project. +## Discussion -## Risks: People stop adding ADRs to the log and context gets lost +There is a need to store big decisions made in a log as a reference point for +the team, help with onboarding new members and give context to others interested +in the project. + +## Risks + +People stop adding ADRs to the log and context gets lost diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 699f7af8ed..8523b4a111 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -4,10 +4,6 @@ title: ADR002: Default Software Catalog File Format description: Architecture Decision Record (ADR) log on Default Software Catalog File Format --- -| Created | Status | -| ---------- | ------ | -| 2020-05-17 | Open | - ## Background Backstage comes with a software catalog functionality, that you can use to track diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index d08bc6a882..878d0d701c 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -4,10 +4,6 @@ title: ADR003: Avoid Default Exports and Prefer Named Exports description: Architecture Decision Record (ADR) log on Avoid Default Exports and Prefer Named Exports --- -| Created | Status | -| ---------- | ------ | -| 2020-05-19 | Open | - ## Context When CommonJS was the primary authoring format, the best practice was to export diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 14c94c4ed1..846ca8feae 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -4,10 +4,6 @@ title: ADR004: Module Export Structure description: Architecture Decision Record (ADR) log on Module Export Structure --- -| Created | Status | -| ---------- | ------ | -| 2020-05-27 | Open | - ## Context With a growing number of exports of packages like `@backstage/core`, it is diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index f91698c5ff..83196c12e2 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -4,10 +4,6 @@ title: ADR005: Catalog Core Entities description: Architecture Decision Record (ADR) log on Catalog Core Entities --- -| Created | Status | -| ---------- | ------ | -| 2020-05-29 | Open | - ## Context We want to standardize on a few core entities that we are tracking in the @@ -58,7 +54,7 @@ discover existing functionality in the ecosystem. APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. -framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs +framework APIs in Swift, Kotlin, Java, C++, TypeScript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 96f594daf7..eecb4ad87a 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -6,7 +6,7 @@ description: Architecture Decision Record (ADR) log on Avoid React.FC and React. ## Context -Facebook has removed `React.FC` from their base template for a Typescript +Facebook has removed `React.FC` from their base template for a TypeScript project. The reason for this was that it was found to be an unnecessary feature with next to no benefits in combination with a few downsides. @@ -43,6 +43,15 @@ const GoodComponent = ({ text, children }: GoodProps) => ( {children} ); + +/* Or as a shorthand, if no specifc child type is required */ +type GoodProps = PropsWithChildren<{ text: string }>; +const GoodComponent = ({ text, children }: GoodProps) => ( +
+
{text}
+ {children} +
+); ``` ## Consequences diff --git a/docs/architecture-decisions/adr010-luxon-date-library.md b/docs/architecture-decisions/adr010-luxon-date-library.md new file mode 100644 index 0000000000..43f18f0f69 --- /dev/null +++ b/docs/architecture-decisions/adr010-luxon-date-library.md @@ -0,0 +1,36 @@ +--- +id: adrs-adr010 +title: ADR010: Use the Luxon Date Library +description: Architecture Decision Record (ADR) for Luxon Date Library +--- + +## Context + +Date formatting (e.g. `a day ago`) and calculations are common within Backstage. +Some of these useful features are not supported by the standard JavaScript +`Date` object. The popular [Moment.js](https://momentjs.com/) library has been +commonly used to fill this gap but suffers from large bundle sizes and mutable +state issues. On top of this, `momentjs` is +[being sunset](https://momentjs.com/docs/#/-project-status/) and the project +recommends using one of the more modern alternative libraries. + +See +[[RFC] Standardized Date & Time Library](https://github.com/backstage/backstage/issues/3401). + +## Decision + +We will use [Luxon](https://moment.github.io/luxon/index.html) as the standard +date library within Backstage. + +`Luxon` provides a similar feature set and API to `Moment.js`, but improves on +its design through immutability and the usage of modern JavaScript APIs (e.g. +`Intl`). This results in smaller bundle sizes while providing a full feature set +and avoids the need for using additional libraries for common date & time tasks. + +## Consequences + +- All core packages and plugins within Backstage should use `Luxon` for any date + manipulation or formatting that cannot be easily accomplished with the native + JavaScript `Date` object. +- Using a single date library avoids having to learn multiple library APIs +- Having a single date library will reduce bundle sizes diff --git a/docs/architecture-decisions/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/features/kubernetes/backstage-k8s-2-deployments.png b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png new file mode 100644 index 0000000000..4a9f2b06fa Binary files /dev/null and b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png differ diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg new file mode 100644 index 0000000000..af04ab63e8 --- /dev/null +++ b/docs/assets/search/architecture.drawio.svg @@ -0,0 +1,541 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + + + + +
+
+
+ App Package: <Route path="/search" element={<... />} /> +
+
+
+
+ + App Package: <Route path="/search" element={<... />}... + +
+
+ + + + +
+
+
+ @backstage/plugin-search-backend +
+
+
+
+ + @backstage/plugin-search-backend + +
+
+ + + + +
+
+
+ Other Plugins +
+ (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Plugins... + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + +
+
+
+ @backstage/plugin-search +
+
+
+
+ + @backstage/plugin-search + +
+
+ + + + + + +
+
+
+ Other Backend Plugin (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Backend Plugin (TechDocs, Catalog, Etc) + +
+
+ + + + +
+
+
+ Search Engine (Elastic, Solr, SaaS, etc.) +
+
+
+
+ + Search Engine (Elastic, Solr, SaaS, et... + +
+
+ + + + +
+
+
+ Search Engine Integration Layer +
+
+
+
+ + Search Engine Integration Layer + +
+
+ + + + + + + + + + + + +
+
+
+ + 1 2 3 + +
+
+
+
+ + 1 2 3 + +
+
+ + + + +
+
+
+ + X number of search results + +
+
+
+
+ + X number of sear... + +
+
+ + + + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + + +
+
+
+ Pass Search +
+ Term and Filters +
+ and then +
+ Return Results +
+
+
+
+ + Pass Search... + +
+
+ + + + +
+
+
+ + Search API + +
+
+
+
+ + Search API + +
+
+ + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + +
+
+
+ + Scheduler + +
+
+
+
+ + Scheduler + +
+
+ + + + +
+
+
+ + Gather Documents + +
+
+
+
+ + Gather D... + +
+
+ + + + + + + +
+
+
+ + Collate Documents +
+ Or Metadata +
+
+
+
+
+ + Collate Docum... + +
+
+ + + + +
+
+
+ + API Endpoint +
+
+
+
+
+
+ + API Endp... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + + Manage Index + +
+ Create, Remove, Replace Documents and Indices +
+
+
+
+
+ + Manage Index... + +
+
+ + + + + + + +
+
+
+ + Compile and Execute Query from Term and Filters + +
+
+
+
+ + Compile and Execut... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index de71141ba0..c3460f98db 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/docs/assets/software-catalog/software-model-core-entities.drawio.svg b/docs/assets/software-catalog/software-model-core-entities.drawio.svg new file mode 100644 index 0000000000..2260e5502e --- /dev/null +++ b/docs/assets/software-catalog/software-model-core-entities.drawio.svg @@ -0,0 +1,3 @@ + + +
dependsOn
dependsOn
Resource
(e.g. SQL Database, S3 bucket, ...)
Resource...
consumesAPI
consumesAPI
API
(e.g. OpenAPI, gRPC API, Avro, Dataset, ...)
API...
providesAPI
providesAPI
Component
(e.g. backend service, data pipeline ...)
Component...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/assets/software-catalog/software-model-core-entities.png b/docs/assets/software-catalog/software-model-core-entities.png deleted file mode 100644 index 60cb283802..0000000000 Binary files a/docs/assets/software-catalog/software-model-core-entities.png and /dev/null differ diff --git a/docs/assets/software-catalog/software-model-entities.drawio.svg b/docs/assets/software-catalog/software-model-entities.drawio.svg new file mode 100644 index 0000000000..7b8b88f224 --- /dev/null +++ b/docs/assets/software-catalog/software-model-entities.drawio.svg @@ -0,0 +1,3 @@ + + +
Domain
Domain
partOf
partOf
System
System
dependsOn
dependsOn
partOf
partOf
Resource
(e.g. SQL Database, S3 bucket, ...)
Resource...
consumesAPI
consumesAPI
API
(e.g. OpenAPI, gRPC API, Avro, Dataset, ...)
API...
providesAPI
providesAPI
partOf
partOf
Component
(e.g. backend service, data pipeline ...)
Component...
partOf
partOf
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/assets/techdocs/aws-s3.drawio.svg b/docs/assets/techdocs/aws-s3.drawio.svg new file mode 100644 index 0000000000..3bb730f91e --- /dev/null +++ b/docs/assets/techdocs/aws-s3.drawio.svg @@ -0,0 +1,144 @@ + + + + + + + + + + +
+
+
+ + + Admin + + User + +
+
+
+
+ + Admin User + +
+
+ + + + +
+
+
+ + + AWS Account + + +
+
+
+
+ + AWS Account + +
+
+ + + + +
+
+
+ + + TechDocs + + User + +
+
+
+
+ + TechDocs User + +
+
+ + + + +
+
+
+ + + S3 Bucket + + +
+
+
+
+ + S3 Bucket + +
+
+ + + + +
+
+
+ + User +
+ policy +
+
+
+
+
+ + User... + +
+
+ + + + +
+
+
+ + Bucket +
+ policy +
+
+
+
+
+ + Bucket... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 8dbae0daa7..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/index.md b/docs/auth/index.md index 3f95efaf61..cf468c7bb7 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -93,6 +93,6 @@ sign-in methods. More details are provided in dedicated sections of the documentation. - [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the - [auth-backend](../../plugins/auth-backend). + [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend). - [Glossary](./glossary.md): Glossary of some common terms related to the auth flows. diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 0655175b58..4209b81279 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -1,9 +1,8 @@ --- id: oauth title: OAuth and OpenID Connect -description: This section describes how Backstage allows plugins to request -OAuth Access Tokens and OpenID Connect ID Tokens on behalf of the user, to be -used for auth to various third party APIs +# prettier-ignore +description: This section describes how Backstage allows plugins to request OAuth Access Tokens and OpenID Connect ID Tokens on behalf of the user, to be used for auth to various third party APIs --- This section describes how Backstage allows plugins to request OAuth Access diff --git a/docs/cli/commands.md b/docs/cli/commands.md new file mode 100644 index 0000000000..726f2a922c --- /dev/null +++ b/docs/cli/commands.md @@ -0,0 +1,606 @@ +--- +id: commands +title: Commands +description: Descriptions of all commands available in the CLI. +--- + +This page lists all commands provided by the Backstage CLI, what they're for, +and where to use them. + +The documentation for each command begins with specifying its scope, this +indicates where the command should be used by selecting from the following list: + +- `app` - A frontend app package, such as `packages/app`. +- `backend` - A backend package, such as `packages/backend`. +- `frontend-plugin` - A frontend plugin package. +- `backend-plugin` - A backend plugin package. +- `root` - The monorepo root. +- `any` - Any kind of package, but not the repo root. + +## help + +This command displays a help summary or detailed help screens for each command. +Below is a cleaned up output of `yarn backstage-cli --help`. + +```text +app:build Build an app for a production release +app: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 + +plugin:build Build a plugin +plugin:diff Diff an existing plugin with the creation template +plugin:serve Serves the dev/ folder of a plugin + +build Build a package for publishing +build-workspace Builds a temporary dist workspace from the provided packages +lint Lint a package +test Run tests, forwarding args to Jest, defaulting to watch mode +clean Delete cache directories + +create-plugin Creates a new plugin in the current repository +remove-plugin Removes plugin in the current repository + +config:print Print the app configuration for the current package +config:check Validate that the given configuration loads and matches schema + +versions:bump Bump Backstage packages to the latest versions +versions:check Check Backstage package versioning + +prepack Prepares a package for packaging before publishing +postpack Restores the changes made by the prepack command + +help [command] display help for command +``` + +## app:build + +Scope: `app` + +Builds a bundle of static content from the app, which can then be served via any +static web server such as `nginx`, or via the +[`app-backend`](https://www.npmjs.com/package/@backstage/plugin-app-backend) +plugin directly from a Backstage backend instance. + +The command also reads and injects static configuration into the bundle. It is +important to note that when deploying using your own static content hosting +solution, this will be the final configuration used in the frontend unless you +for example hook in configuration loading from the backend. When using the +`nginx` based Dockerfile in this repo along with its included run script, +`APP_CONFIG_` environment variables will be injected into the frontend, and when +serving using the `app-backend` plugin, the configuration is completely injected +from the backend and the configuration at the time of calling this command will +not be used. + +Note that even when injecting configuration at runtime, it is not possible to +change the base path of the app. For example, if you at build time have +`app.baseUrl` set to `http://dev-app.com/my-app`, you can change that to +`https://prod-app.com/my-app`, but not to `https://prod-app.com`, as that would +change the path. + +During the build, the following variables are set: + +```java +process.env.NODE_ENV = 'production'; +process.env.BUILD_INFO = { + cliVersion: '0.4.0', // The version of the CLI package + gitVersion: 'v0.4.0-86-ge54815618', // output of `git describe --always` + packageVersion: '1.0.5', // The version of the app package itself + timestamp: 1678900000000, // Date.now() when the build started + commit: 'e548156182a973ed4b459e18533afc22c85ffff8', // output of `git rev-parse HEAD` +}; +``` + +Some CI environments do not properly report correct resource limits, potentially +leading to errors such as `ENOMEM` during compilation. If you run into this +issue you can limit the parallelization of the build process by setting the +environment variable `BACKSTAGE_CLI_BUILD_PARALLEL`, which is forwarded to the +[`terser-webpack-plugin`](https://github.com/webpack-contrib/terser-webpack-plugin#parallel). +You can set it to `false` or `1` to completely disable parallelization, but +usually a low value such as `2` is enough. + +```text +Usage: backstage-cli app:build + +Options: + --stats Write bundle stats to output directory + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## app:diff + +Scope: `app` + +Diff an existing app with the template used in `@backstage/create-app`. This +will verify that your app package has not diverged from the template, and can be +useful to run after updating the version of `@backstage/cli` in your app. + +This command is experimental and may be removed in the future. + +```text +Usage: backstage-cli app:diff + +Options: + --check Fail if changes are required + --yes Apply all changes + -h, --help display help for command +``` + +## app:serve + +Scope: `app` + +Serve an app for local development. This starts up a local development server, +using a bundling configuration that is quite similar to that of the `app:build` +command, but with development features such as React Hot Module Replacement, +faster sourcemaps, no minification, etc. + +The static configuration is injected into the frontend, but it does not support +watching, meaning that changes in for example `app-config.yaml` are not +reflected until the serve process is restarted. + +During the build, the following variables are set: + +```java +process.env.NODE_ENV = 'development'; +process.env.BUILD_INFO = { /* See app:build */ }; +``` + +The server listening configuration is controlled through the static +configuration. The `app.baseUrl` determines the listening host and port, as well +as whether HTTPS is used or not. It is also possible to override the listening +host and port if needed by setting `app.listen.host` and `app.listen.port`. + +```text +Usage: backstage-cli app:serve [options] + +Options: + --check Enable type checking and linting + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## backend:build + +Scope: `backend-plugin` + +This builds a backend package for publishing and use in production. The build +output is written to `dist/`. Be sure to list any additional file that the +package depends on at runtime in the `"files"` field inside `package.json`, a +common example being the `migrations` directory. + +```text +Usage: backstage-cli backend:build [options] + +Options: + -h, --help display help for command +``` + +## backend:bundle + +Scope: `backend` + +Bundle the backend and all of its local dependencies into a deployment archive. +The archive is written to `dist/bundle.tar.gz`, and contains the packaged +version of all dependencies of the target package, along with the target package +itself. The layout of the packages in the archive is the same as the directory +layout in the target monorepo, and the bundle also contains the root +`package.json` and `yarn.lock`. + +To use the bundle, extract it into a target directory, run +`yarn install --production`, and then start the target backend package using for +example `node package/backend`. + +The `dist/bundle.tar.gz` is accompanied by a `dist/skeleton.tar.gz`, which has +the same layout, but only contains `package.json` files and `yarn.lock`. This +can be used to run a `yarn install` in environments that will benefit from the +caching that this enables, such as Docker image builds. To use the skeleton +archive, simply extract it first, run install, and then extract the main bundle. + +The following is an example of a `Dockerfile` that can be used to package the +output of `backstage-cli backend:bundle` into an image: + +```Dockerfile +FROM node:14-buster +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` + +Builds a Docker image of the backend package, forwarding all unknown options to +`docker image build`. For example: + +```bash +yarn backstage-cli backend:build-image --build --tag my-backend-image +``` + +The image is built using the backend package along with all of its local package +dependencies. It expects to find a `Dockerfile` at the root of the backend +package, which will be used during the build. + +The Dockerfile is **NOT** executed within the package or repo itself. Because +the packages in the repo itself are configured for development instead of +production use, the final Docker build happens in a separate temporary +directory, to which the backend package and dependencies have been copied. Only +files listed within the `"files"` field within each package's `package.json` are +copied over, along with the root `package.json`, `yarn.lock`, and any +`app-config.*.yaml` files. + +During the build a `skeleton.tar` file is created and put at the repo root. This +file contains the `package.json` of each included package, which together with +the root `package.json` and `yarn.lock` can be used to run a cached +`yarn install` before the full production builds of all the packages are copied +over, providing a significant speedup if Docker build layer caching available. + +This command is experimental and we hope to be able to replace it with one that +is less integrated directly with Docker, and also supports multi-stage Docker +builds. It is possible to replicate most of what this command does by manually +building each package, and then use the `build-workspace` to create the +temporary workspace, and finally copy over any additional files to the workspace +and execute the Docker build within it. + +```text +Usage: backstage-cli backend:build-image [options] + +Options: + --build Build packages before packing them into the image + --backstage-cli-help display help for command +``` + +## backend:dev + +Scope: `backend`, `backend-plugin` + +Starts a backend package in development mode, with watch mode enabled for all +local dependencies. + +```text +Usage: backstage-cli backend:dev [options] + +Options: + --check Enable type checking and linting + --inspect Enable debugger + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## create-plugin + +Scope: `root` + +Creates a new plugin within the repository. This command is typically wrapped up +in the root `package.json` to be executed with `yarn create-plugin`, using +options that are appropriate for the organization that owns the app repo. A +recommended scope for internal packages is `@internal`. + +```text +Usage: backstage-cli create-plugin [options] + +Options: + --backend Create plugin with the backend dependencies as default + --scope <scope> npm scope + --npm-registry <URL> npm registry URL + --no-private Public npm package + -h, --help display help for command +``` + +## remove-plugin + +Scope: `root` + +A utility to remove a plugin from a repo, essentially undoing everything that +was done by `create-plugin`. + +This is primarily intended as a utility for manual tests and end to end testing +scripts. + +```text +Usage: backstage-cli remove-plugin [options] + +Options: + -h, --help display help for command +``` + +## plugin:build + +Scope: `frontend-plugin` + +Build a frontend plugin for publishing to a package registry. There is no need +to run this command during development or even in CI unless the package is being +published. The `app:bundle` command does not use the output for this command +when bundling local package dependencies. + +The output is written to a `dist/` folder. It also outputs type declarations for +the plugin, and therefore requires `yarn tsc` to have been run first. The input +type declarations are expected to be found within `dist-types/` at the root of +the monorepo. + +```text +Usage: backstage-cli plugin:build [options] + +Options: + -h, --help display help for command +``` + +## plugin:serve + +Scope: `frontend-plugin` + +Serves a frontend plugin by itself for isolated development. The serve task +itself is essentially identical to `app:serve`, but the entrypoint is instead +set to the `dev/` folder within the plugin. + +The `dev/` folder typically contains a small wrapper script that hooks up any +necessary mock APIs or other things that are needed for the plugin to function. +The `@backstage/dev-utils` package provides utilities to that end. + +```text +Usage: backstage-cli plugin:serve [options] + +Options: + --check Enable type checking and linting + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## plugin:diff + +Scope: `frontend-plugin` + +Compares a frontend plugin to the `create-plugin` template, making sure that it +hasn't diverged from the template and recommending updates when it has. A good +practice is to run this command after updating the version of the CLI in a +project. + +```text +Usage: backstage-cli plugin:diff [options] + +Options: + --check Fail if changes are required + --yes Apply all changes + -h, --help display help for command +``` + +## build + +Scope: `any` + +Build a single package for publishing, just like the `plugin:build` and +`backend:build` commands. This command is intended for standalone packages that +aren't plugins, and for example support building of isomorphic packages for +usage in both the frontend and backend. + +For frontend packages you'll want to include `esm` output, and for backend +packages `cjs`. Whether to include `types` depends on if you need type +declarations for the package, and also requires `yarn tsc` to have been run +first. + +```text +Usage: backstage-cli build [options] + +Options: + --outputs <formats> List of formats to output [types,cjs,esm] + -h, --help display help for command +``` + +## lint + +Scope: `any` + +Lint a package. In addition to the default `eslint` behavior, this command will +include TypeScript files, treat warnings as errors, and default to linting the +entire directory if no specific files are listed. + +```text +Usage: backstage-cli lint [options] + +Options: + --format <format> Lint report output format (default: "eslint-formatter-friendly") + --fix Attempt to automatically fix violations + -h, --help display help for command +``` + +## test + +Scope: `any` + +Run tests, forwarding all unknown options to Jest, and defaulting to watch mode. +When executing the tests, `process.env.NODE_ENV` will be set to `"test"`. + +This command uses a default Jest configuration that is included in the CLI, +which is set up with similar goals for speed, scale, and working within a +monorepo. The configuration sets the `src` as the root directory, enforces the +`.test.` infix for tests, and uses `src/setupTests.ts` as the test setup +location. + +If needed, the configuration can be extended using a `"jest"` field in +`package.json`, both within the target package and the monorepo root, with +configuration in the target package taking precedence. Refer to the +[Jest configuration documentation](https://jestjs.io/docs/en/configuration) for +a full list of configuration options. + +In addition to the Jest configuration there's an optional `transformModules` +option, which is an array of module names to include in transformations. +Normally modules inside `node_modules` are not transformed, but there are cases +were published packages are not transpiled far enough to be usable by Jest, in +which case you need to enable transform of them. + +Another way to override the Jest configuration is to place a `jest.config.js` or +`jest.config.ts` file in the package root. As opposed to the `package.json` way +of overriding config, this completely removes the base config, and so you need +to set it up from scratch. + +```text +Usage: backstage-cli test [options] + +Options: + --backstage-cli-help display help for command +``` + +## config:print + +Scope: `root` + +Print the static configuration, defaulting to reading `app-config.yaml` in the +repo root, using schema collected from all local packages in the repo. + +For example, to validate that a given configuration value is visible in the +frontend when building the `my-app` package, you can use the following: + +```bash +yarn backstage-cli config:print --frontend --package my-app +``` + +```text +Usage: backstage-cli config:print [options] + +Options: + --package <name> Only load config schema that applies to the given package + --frontend Print only the frontend configuration + --with-secrets Include secrets in the printed configuration + --format <format> Format to print the configuration in, either json or yaml [yaml] + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## config:check + +Scope: `root` + +Validate that static configuration loads and matches schema, defaulting to +reading `app-config.yaml` in the repo root and using schema collected from all +local packages in the repo. + +```text +Usage: backstage-cli config:check [options] + +Options: + --package <name> Only load config schema that applies to the given package + --config <path> Config files to load instead of app-config.yaml (default: []) + -h, --help display help for command +``` + +## versions:bump + +Scope: `root` + +Bump all `@backstage` packages to the latest versions. This checks for updates +in the package registry, and will update entries both in `yarn.lock` and +`package.json` files when necessary. + +```text +Usage: backstage-cli versions:bump [options] + +Options: + -h, --help display help for command +``` + +## versions:check + +Scope: `root` + +Validate `@backstage` dependencies within the repo, making sure that there are +no duplicates of packages that might lead to breakages. For example, +`@backstage/core` must not be loaded in twice, so having two different versions +of it installed will cause this command to exit with an error. + +By supplying the `--fix` flag the command will attempt to fix any conflict that +can be resolved by editing `yarn.lock`, but will not attempt to search for +remote updates or modify any `package.json` files. + +```text +Usage: backstage-cli versions:check [options] + +Options: + --fix Fix any auto-fixable versioning problems + -h, --help display help for command +``` + +## prepack + +Scope: `any` + +This command should be added as `scripts.prepack` in all packages. It enables +packaging- and publish-time overrides for fields inside `packages.json`. + +The checked in version of all packages in a Backstage monorepo are tailored for +local development, and as such `main` and similar fields inside `package.json` +point to development source, i.e. `src/index.ts`. Using this when publishing +would lead to a broken package, since `src/` is not included in the published +package and we instead need to point to files in the `dist/` directory. This +command allows for those fields to be rewritten when needed, and does so by +copying all fields within `publishConfig` to the top-level of each +`package.json`, skipping `access`, `registry`, and `tag`. + +The need for this command may be removed in the future, as this exact method of +overriding fields for publishing is already supported by some package managers. + +```text +Usage: backstage-cli prepack [options] + +Options: + -h, --help display help for command +``` + +## postpack + +Scope: `any` + +This should be added as `scripts.postpack` in all packages. It restores +`package.json` to what it looked like before calling the `prepack` command. + +```text +Usage: backstage-cli postpack [options] + +Options: + -h, --help display help for command +``` + +## clean + +Scope: `any` + +Remove cache and output directories. + +```text +Usage: backstage-cli clean [options] + +Options: + -h, --help display help for command +``` + +## build-workspace + +Scope: `any`, `root` + +Builds a mirror of the workspace using the packaged production version of each +package. This essentially calls `yarn pack` in each included package and unpacks +the resulting archive in the target `workspace-dir`. + +```text +Usage: backstage-cli build-workspace [options] <workspace-dir> +``` diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000000..1b2e01be62 --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,108 @@ +--- +id: index +title: Overview +description: Overview of the Backstage CLI +--- + +## Summary + +Backstage provides an opinionated set of tooling for both frontend and backend +development. It is delivered through the +[`@backstage/cli`](https://www.npmjs.com/package/@backstage/cli) package and +executed either directly through `yarn backstage-cli ` or within +`package.json` scripts. When creating an app using +[`@backstage/create-app`](https://www.npmjs.com/package/@backstage/create-app) +it contains package scripts for executing the most common commands. + +Under the hood the CLI uses [Webpack](https://webpack.js.org/) for bundling, +[Rollup](https://rollupjs.org/) for building packages, +[Jest](https://jestjs.io/) for testing, and [eslint](https://eslint.org/) for +linting. It also includes custom tooling for working within Backstage apps, for +example for keeping the app up to date and verifying static configuration. + +For a full list of CLI commands, see the [commands](./commands.md) page. + +## Introduction + +A goal of Backstage is to provide a delightful developer experience in and +around the project. Creating new apps and plugins should be simple, iteration +speed should be fast, and the overhead of maintaining custom tooling should be +minimal. As a part of accomplishing this goal, Backstage provides its own set of +opinionated tooling, delivered primarily through the +[`@backstage/cli`](https://www.npmjs.com/package/@backstage/cli) package. + +The `@backstage/cli` package provides a single executable script, +`backstage-cli`, which you can run directly with `yarn` or within a script in +`package.json`. If you have a Backstage app set up, you can try out the +following command to print the top-level help page of the CLI: + +```text +yarn backstage-cli --help +``` + +If you are familiar with [`create-react-app`](https://create-react-app.dev/) you +may recognize the pattern of bundling tooling up as a CLI, as it uses a package +called [`react-scripts`](https://www.npmjs.com/package/react-scripts) to bring +most of the functionality into the created project. The Backstage equivalent of +`create-react-app` is +[`@backstage/create-app`](https://www.npmjs.com/package/@backstage/create-app), +and the equivalent of `react-scripts` is `@backstage/cli`. There are however a +couple of key differences between the two. Most notably, Backstage apps are +monorepos and the CLI is tailored for that environment. It provides tooling both +for bundling and developing full end-user apps, but also for developing, +building and publishing individual packages within the monorepo, as well as +tooling that is more unique to Backstage, such as commands for working with +static configuration. + +## Opinionated Tooling + +The Backstage CLI is highly opinionated in what tools are used and how they are +configured. It is tailored for development in large TypeScript monorepos with +hundreds of separate packages, but with the ability to have edits anywhere in +the codebase reflected within a few seconds. The build output is also optimized +for this setup, and aims to provide an excellent user experience with fast page +load times in modern browsers, rather than a wide range of support. + +While the Backstage tooling is opinionated in how to develop and build packages, +it is also possible to use your own tooling either partially or fully. For +example, the CLI provides a command for building a plugin package for +publishing, but the output is a quite standard combination of transpiled +JavaScript and TypeScript type declarations. The usage of the command from the +CLI can therefore easily be replaced with other tools if necessary. + +Just like `react-scripts`, the Backstage CLI does not provide many hooks for +overriding or customizing the build process. This is to allow for evolution of +the CLI without having to take a wide API surface into account. This allows us +to quickly iterate and improve the tooling, as well as to more easily keep +dependencies up to date. + +## Opinions & Goals + +In no particular order, this is a list of opinions and goals that guide the +design and development of the Backstage CLI: + +- All you need for development is `yarn start`, there should be no need to + manually build packages or run other separate tasks. +- Development experience comes first. The toolchain is optimized for keeping + development smooth, rather than making it easy to for example build and + publish packages. +- Type checking and linting is left for text editors and Continuous Integration. + Most text editors provide tooling for these checks, and running them a second + time during compilation slows down iteration speed and consumes more system + resources. +- Backstage is run in modern browsers. We keep transpilation lightweight and + rely on modern technologies such as HTTP/2 to optimize frontend speed. + +## Glossary + +- **Package** - A package in the Node.js ecosystem, often published to a package + registry such as [NPM](https://www.npmjs.com/). +- **Monorepo** - A project layout that consists of multiple packages within a + single project, where packages are able to have local dependencies on each + other. Often enabled through tooling such as [lerna](https://lerna.js.org/) + and [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) +- **Local Package** - One of the packages within a monorepo. These package may + or may not also be published to a package registry. +- **Bundle** - A collection of the deployment artifacts. The output of the + bundling process, which brings a collection of packages into a single + collection of deployment artifacts. diff --git a/docs/conf/defining.md b/docs/conf/defining.md index ead5c4ff67..34b9b11977 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -36,6 +36,10 @@ export interface Config { * @visibility frontend */ baseUrl: string; + + // Use @items. to assign annotations to primitive array items + /** @items.visibility frontend */ + myItems: string[]; }; } ``` @@ -83,6 +87,12 @@ object will be available in the frontend. The full ancestry does not need to have correctly defined visibilities however, so it is enough to only for example declare the visibility of a leaf node of `type: "string"`. +| `visibility` | | +| ------------ | ------------------------------------------------------------------ | +| `frontend` | Visible in frontend and backend | +| `backend` | (Default) Only in backend | +| `secret` | Only in backend and may be excluded from logs for security reasons | + ## Validation Schemas can be validated using the `backstage-cli config:check` command. If you diff --git a/docs/conf/index.md b/docs/conf/index.md index a6f1d1f6f7..ef6faffd8b 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -18,8 +18,8 @@ allowing for customization. Configuration is stored in YAML files where the defaults are `app-config.yaml` and `app-config.local.yaml` for local overrides. Other sets of files can by loaded by passing `--config ` flags. The configuration files themselves -contain plain YAML, but with support for loading in secrets from various sources -using for example `$env` and `$file` keys. +contain plain YAML, but with support for loading in data and secrets from +various sources using for example `$env` and `$file` keys. It is also possible to supply configuration through environment variables, for example `APP_CONFIG_app_baseUrl=https://staging.example.com`. However these diff --git a/docs/conf/writing.md b/docs/conf/writing.md index f28dd5b2d3..057675c98e 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -97,13 +97,13 @@ order: - If no config flags are provided, `app-config.local.yaml` has higher priority than `app-config.yaml`. -## Secrets and Dynamic Data +## Includes and Dynamic Data -Secrets are supported via special data loading keys that are prefixed with `$`, -which in turn provide a number of different ways to read in secrets. To load a -configuration value as a secret, supply an object with one of the special secret -keys, for example `$env` or `$file`. A full list of supported secret keys can be -found below. For example, the following will read the config key +Includes are supported via special data loading keys that are prefixed with `$`, +which in turn provide a number of different ways to read in data. To load in an +external configuration value, supply an object with one of the special include +keys, for example `$env` or `$file`. A full list of supported include keys can +be found below. For example, the following will read the config key `backend.mySecretKey` from the environment variable `MY_SECRET_KEY`: ```yaml @@ -114,43 +114,42 @@ backend: With the above configuration, calling `config.getString('backend.mySecretKey')` will return the value of the environment variable `MY_SECRET_KEY` when the -backend started up. All secrets are loaded at startup, so changing the contents -of secret files or environment variables will not be reflected at runtime. +backend started up. All includes are loaded at startup, so changing the contents +of files or environment variables will not be reflected at runtime. -As hinted at, secrets can be loaded from a bunch of different sources, and can -be extended with more. Below is a list of the currently supported methods for -loading secrets. +Below is a list of the currently supported methods for loading includes. -### Env Secrets +### Env Includes -This reads a secret from an environment variable. For example, the following -config loads the secret from the `MY_SECRET` env var. +This reads a string value from an environment variable. For example, the +following configuration loads the string value from the `MY_SECRET` environment +variable. ```yaml $env: MY_SECRET ``` -### File Secrets +### File Includes -This reads a secret from the entire contents of a file. The file path is -relative to the `app-config.yaml` the defines the secrets. For example, the -following reads the contents of `my-secret.txt` relative to the config file -itself: +This reads a string value from the entire contents of a text file. The file path +is relative to the source config file. For example, the following reads the +contents of `my-secret.txt` relative to the config file itself: ```yaml $file: ./my-secret.txt ``` -### Data File Secrets +### Including Files -This reads secrets from a path within a JSON-like data file. The file path -behaves similar to file secrets, but with the addition of a url fragment that is -used to point to a specific value inside the file. Supported file extensions are -`.json`, `.yaml`, and `.yml`. For example, the following would read out -`my-secret-key` from `my-secrets.json`: +The `$include` keyword can be used to load configuration values from an external +file. It's able to load and parse data from `.json`, `.yml`, and `.yaml` files. +It's also possible to include a url fragment (`#`) to point to a value at the +given path in the file, using a dot-separated list of keys. + +For example, the following would read `my-secret-key` from `my-secrets.json`: ```yaml -$data: ./my-secrets.json#deployment.key +$include: ./my-secrets.json#deployment.key ``` Example `my-secrets.json` file: @@ -162,3 +161,19 @@ Example `my-secrets.json` file: } } ``` + +## Environment Variable Substitution + +Configuration files support environment variable substitution via a `${MY_VAR}` +syntax. For example: + +```yaml +app: + baseUrl: https://${HOST} +``` + +Note that all environment variables must be available, or the entire +configuration value will evaluate to `undefined`. + +The substitution syntax can be escaped using `$${...}`, which will be resolved +as `${...}`. diff --git a/docs/dls/figma.md b/docs/dls/figma.md index c5a33d15c2..21a05d2818 100644 --- a/docs/dls/figma.md +++ b/docs/dls/figma.md @@ -1,8 +1,8 @@ --- id: figma title: Figma -description: Documentation on using Figma to build your own plugins for -Backstage +# prettier-ignore +description: Documentation on using Figma to build your own plugins for Backstage --- We have a [Figma component library](https://www.figma.com/@backstage) that you diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md new file mode 100644 index 0000000000..3e138a7a0b --- /dev/null +++ b/docs/features/kubernetes/configuration.md @@ -0,0 +1,130 @@ +--- +id: configuration +title: Configuring Kubernetes integration +sidebar_label: Configuration +# prettier-ignore +description: Configuring the Kubernetes integration for Backstage expose your entity's objects +--- + +Configuring the Backstage Kubernetes integration involves two steps: + +1. Enabling the backend to collect objects from your Kubernetes cluster(s). +2. Surfacing your Kubernetes objects in catalog entities + +## Configuring Kubernetes Clusters + +The following is a full example entry in `app-config.yaml`: + +```yaml +kubernetes: + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: gke-cluster-1 + authProvider: 'google' +``` + +### `serviceLocatorMethod` + +This configures how to determine which clusters a component is running in. + +Currently, the only valid value is: + +- `multiTenant` - This configuration assumes that all components run on all the + provided clusters. + +### `clusterLocatorMethods` + +This is an array used to determine where to retrieve cluster configuration from. + +Currently, the only valid cluster locator method is: + +- `config` - This cluster locator method will read cluster information from your + app-config (see below). + +### `clusters` + +Used by the `config` cluster locator method to construct Kubernetes clients. + +### `clusters.\*.url` + +The base URL to the Kubernetes control plane. Can be found by using the +"Kubernetes master" result from running the `kubectl cluster-info` command. + +### `clusters.\*.name` + +A name to represent this cluster, this must be unique within the `clusters` +array. Users will see this value in the Service Catalog Kubernetes plugin. + +### `clusters.\*.authProvider` + +This determines how the Kubernetes client authenticates with the Kubernetes +cluster. Valid values are: + +| Value | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | +| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | + +### `clusters.\*.serviceAccount` (optional) + +The service account token to be used when using the `serviceAccount` auth +provider. + +### Role Based Access Control + +The current RBAC permissions required are read-only cluster wide, for the +following objects: + +- pods +- services +- configmaps +- deployments +- replicasets +- horizontalpodautoscalers +- ingresses + +## Surfacing your Kubernetes components as part of an entity + +There are two ways to surface your Kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Common `backstage.io/kubernetes-id` label + +#### Adding the entity annotation + +In order for Backstage to detect that an entity has Kubernetes components, the +following annotation should be added to the entity's `catalog-info.yaml`: + +```yaml +annotations: + 'backstage.io/kubernetes-id': dice-roller +``` + +#### Labeling Kubernetes components + +In order for Kubernetes components to show up in the service catalog as a part +of an entity, Kubernetes components themselves can have the following label: + +```yaml +'backstage.io/kubernetes-id': +``` + +### Label selector query annotation + +You can write your own custom label selector query that Backstage will use to +lookup the objects (similar to `kubectl --selector="your query here"`). Review +the +[labels and selectors Kubernetes documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) +for more info. + +```yaml +'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' +``` diff --git a/docs/features/kubernetes/index.md b/docs/features/kubernetes/index.md new file mode 100644 index 0000000000..26794eca10 --- /dev/null +++ b/docs/features/kubernetes/index.md @@ -0,0 +1,30 @@ +--- +id: overview +title: Kubernetes +sidebar_label: Overview +description: Monitoring Kubernetes based services with the service catalog +--- + +Kubernetes in Backstage is a tool that's designed around the needs of service +owners, not cluster admins. Now developers can easily check the health of their +services no matter how or where those services are deployed — whether it's on a +local host for testing or in production on dozens of clusters around the world. + +It will elevate the visibility of errors where identified, and provide drill +down about the deployments, pods, and other objects for a service. + +![Kubernetes plugin screenshot](../../assets/features/kubernetes/backstage-k8s-2-deployments.png) + +The feature is made up of two plugins: +[`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) +and +[`@backstage/plugin-kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). + +The frontend plugin exposes information to the end user in a digestible way, +while the backend wraps the mechanics to connect to Kubernetes clusters to +collect the relevant information. + +## Let's use it! + +To get started, first you must [install the Kubernetes plugins](installation.md) +and then [configure them](configuration.md). diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md new file mode 100644 index 0000000000..9e531de655 --- /dev/null +++ b/docs/features/kubernetes/installation.md @@ -0,0 +1,119 @@ +--- +id: installation +title: Installation +description: Installing Kubernetes plugin into Backstage +--- + +The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when +viewing entities in the software catalog. + +If you haven't setup Backstage already, start +[here](../../getting-started/index.md). + +## Adding the Kubernetes frontend plugin + +The first step is to add the frontend Kubernetes plugin to your Backstage +application. Navigate to your new Backstage application directory. And then to +your `packages/app` directory, and install the `@backstage/plugin-kubernetes` +package. + +```bash +cd my-backstage-app/ +cd packages/app +yarn add @backstage/plugin-kubernetes +``` + +Once the package has been installed, you need to import the plugin in your app. +Add the following to `packages/app/src/plugins.ts`: + +`plugins.ts`: + +```typescript +export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; +``` + +Now, add the "Kubernetes" tab to the catalog entity page. In +`packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get +to the tab, and add the tab itself. + +`EntityPage.tsx`: + +```tsx +import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; + +// ... + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + // ... + } + /> + // ... + +); +``` + +That's it! But now, we need the Kubernetes Backend plugin for the frontend to +work. + +## Adding Kubernetes Backend plugin + +Navigate to `packages/backend` of your Backstage app, and install the +`@backstage/plugin-kubernetes-backend` package. + +```bash +cd my-backstage-app/ +cd packages/backend +yarn add @backstage/plugin-kubernetes-backend +``` + +Create a file called `kubernetes.ts` inside `packages/backend/src/plugins/` and +add the following + +`kubernetes.ts`: + +```typescript +import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} +``` + +And import the plugin to `packages/backend/src/index.ts`. There are three lines +of code you'll need to add, and they should be added near similar code in your +existing Backstage backend. + +`index.ts`: + +```typescript +import kubernetes from './plugins/kubernetes'; + +// ... + +const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + +// ... + +apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); +``` + +That's it! The Kubernetes frontend and backend have now been added to your +Backstage app. + +## Running Backstage locally + +Start the frontend and the backend app by +[running backstage locally](../../getting-started/running-backstage-locally.md). + +## Configuration + +After installing the plugins in the code, you'll need to then +[configure them](configuration.md). diff --git a/docs/features/search/README.md b/docs/features/search/README.md new file mode 100644 index 0000000000..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 438f874990..2b173c4b3e 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -31,6 +31,7 @@ we recommend that you name them `catalog-info.yaml`. - [Kind: Resource](#kind-resource) - [Kind: System](#kind-system) - [Kind: Domain](#kind-domain) +- [Kind: Location](#kind-location) ## Overall Shape Of An Entity @@ -59,7 +60,7 @@ software catalog API. }, "spec": { "lifecycle": "production", - "owner": "artist-relations@example.com", + "owner": "artist-relations-team", "type": "website" } } @@ -83,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 @@ -130,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. @@ -267,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. @@ -380,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 ``` @@ -406,7 +421,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.lifecycle` [required] @@ -426,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 @@ -439,40 +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.implementsApis` [optional] +### `spec.system` [optional] -**NOTE**: This field was marked for deprecation on Nov 25nd, 2020. It will be -removed entirely from the model on Dec 14th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. +An [entity reference](#string-references) to the system that the component +belongs to, e.g. `artist-engagement-portal`. This field is optional. -Links APIs that are implemented by the component, e.g. `artist-api`. This field -is optional. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +### `spec.subcomponentOf` [optional] -This field has the same behavior as `spec.providesApis`. +An [entity reference](#string-references) to another component of which the +component is a part, e.g. `spotify-ios-app`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`Component`](#kind-component) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ### `spec.providesApis` [optional] -Links APIs that are provided by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +provided by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`providesApi`, and reverse `apiProvidedBy`](well-known-relations.md#providesapi-and-apiprovidedby) | ### `spec.consumesApis` [optional] -Links APIs that are consumed by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +consumed by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`consumesApi`, and reverse `apiConsumedBy`](well-known-relations.md#consumesapi-and-apiconsumedby) | ## Kind: Template @@ -557,7 +577,7 @@ The current set of well-known and common values for this field is: - `service` - a backend service, typically exposing an API - `website` - a website -- `library` - a software library, such as an NPM module or a Java library +- `library` - a software library, such as an npm module or a Java library ### `spec.templater` [required] @@ -611,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: @@ -677,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 @@ -690,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] @@ -722,10 +752,12 @@ metadata: description: The infra business unit spec: type: business-unit + profile: + displayName: Infrastructure + email: infrastructure@example.com + picture: https://example.com/groups/bu-infrastructure.jpeg parent: ops - ancestors: [ops, global-synergies, acme-corp] children: [backstage, other] - descendants: [backstage, other, team-a, team-b, team-c, team-d] ``` In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) @@ -748,6 +780,14 @@ Some common values for this field could be: - `product-area` - `root` - as a common virtual root of the hierarchy, if desired +### `spec.profile` [optional] + +Optional profile information about the group, mainly for display purposes. All +fields of this structure are also optional. The email would be a group email of +some form, that the group may wish to be used for contacting them. The picture +is expected to be a URL pointing to an image that's representative of the group, +and that a browser could fetch and render on a group page or similar. + ### `spec.parent` [optional] The immediate parent group in the hierarchy, if any. Not all groups must have a @@ -755,30 +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). -### `spec.ancestors` [required] - -**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be -removed entirely from the model on Dec 6th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. - -The recursive list of parents up the hierarchy, by stepping through parents one -by one. The list must be present, but may be empty if `parent` is not present. -The first entry in the list is equal to `parent`, and then the following ones -are progressively farther up the hierarchy. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`childOf`, and reverse `parentOf`](well-known-relations.md#parentof-and-childof) | ### `spec.children` [required] @@ -788,30 +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). -### `spec.descendants` [required] - -**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be -removed entirely from the model on Dec 6th, 2020 in the repository and will not -be present in released packages following the next release after that. Please -update your code to not consume this field before the removal date. - -The immediate and recursive child groups of this group in the hierarchy -(children, and children's children, etc.). The list must be present, but may be -empty if there are no child groups. The items are not guaranteed to be ordered -in any particular way. - -The entries of this array are -[entity references](https://backstage.io/docs/features/software-catalog/references), -with the default kind `Group` and the default namespace equal to the same -namespace as the user. Only `Group` entities may be referenced. Most commonly, -these entries point to groups in the same namespace, so in those cases it is -sufficient to enter only the `metadata.name` field of those groups. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`hasMember`, and reverse `memberOf`](well-known-relations.md#memberof-and-hasmember) | ## Kind: User @@ -867,20 +869,252 @@ 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 + +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Location` | + +A location is a marker that references other places to look for catalog data. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: org-data +spec: + type: url + targets: + - http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml + - http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml +``` + +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 `Location`, respectively. + +### `spec.type` [optional] + +The single location type, that's common to the targets specified in the spec. If +it is left out, it is inherited from the location type that originally read the +entity data. For example, if you have a `url` type location, that when read +results in a `Location` kind entity with no `spec.type`, then the referenced +targets in the entity will implicitly also be of `url` type. This is useful +because you can define a hierarchy of things in a directory structure using +relative target paths (see below), and it will work out no matter if it's +consumed locally on disk from a `file` location, or as uploaded on a VCS. + +### `spec.target` [optional] + +A single target as a string. Can be either an absolute path/URL (depending on +the type), or a relative path such as `./details/catalog-info.yaml` which is +resolved relative to the location of this Location entity itself. + +### `spec.targets` [optional] + +A list of targets as strings. They can all be either absolute paths/URLs +(depending on the type), or relative paths such as `./details/catalog-info.yaml` +which are resolved relative to the location of this Location entity itself. 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/software-templates/installation.md b/docs/features/software-templates/installation.md index 724b86aed1..8d2fa7727d 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -173,7 +173,12 @@ and access to a running Docker daemon. You can create a GitHub access token docs on creating private GitHub access tokens is available [here](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). Note that the need for private GitHub access tokens will be replaced with GitHub -Apps integration further down the line. +Apps integration further down the line by using the existing `integrations` +config. + +> Note: Some of this configuration may already be set up as part of your +> `app-config.yaml`. We're moving away from the duplicated config for +> authentication in the `scaffolder` section and using `integrations` instead. #### GitHub @@ -187,10 +192,14 @@ by specifying `visibility` option. Valid options are `public`, `private` and public within the enterprise. ```yaml +integrations: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: github: - token: - $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' ``` @@ -201,10 +210,9 @@ allows to configure the private access token and the base URL of a GitLab instance: ```yaml -scaffolder: +integrations: gitlab: - api: - baseUrl: https://gitlab.com + - host: gitlab.com token: $env: GITLAB_TOKEN ``` @@ -218,10 +226,9 @@ will hopefully support on-prem installations as well but that has not been verified. ```yaml -scaffolder: +integrations: azure: - baseUrl: https://dev.azure.com/{your-organization} - api: + - host: dev.azure.com token: $env: AZURE_TOKEN ``` diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 16131a1ded..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 @@ -96,26 +94,38 @@ Build a widget (plugin) framework so that contributors can easily contribute features to TechDocs - that others can use. And, also, so that we can easily migrate Spotify's existing TechDocs features to open source. -## Structure +## Platforms Supported -- [Getting Started] -- [Concepts] -- [Creating and Publishing Documentation] -- [FAQ] +See [TechDocs Architecture](architecture.md) to get an overview of where these +providers are used. + +| Source Code Hosting Provider | Support Status | +| ---------------------------- | -------------- | +| GitHub | Yes ✅ | +| GitHub Enterprise | Yes ✅ | +| BitBucket | Yes ✅ | +| Azure DevOps | Yes ✅ | +| GitLab | Yes ✅ | +| GitLab Enterprise | Yes ✅ | + +| File Storage Provider | Support Status | +| --------------------------------- | ----------------------------------------------------------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | + +[Reach out to us](#feedback) if you want to request more platforms. ## Tech Stack -| Stack | Location | -| ------------------------------------------- | -------------------------------------------------------- | -| Frontend | [`@backstage/plugin-techdocs`][techdocs/frontend] | -| Backend | [`@backstage/plugin-techdocs-backend`][techdocs/backend] | -| Docker Container (for generating doc sites) | [`techdocs-container`][techdocs/container] | -| CLI (for local development) | [`@techdocs/cli`][techdocs/cli] | +| Stack | Location | +| ----------------------------------------------- | -------------------------------------------------------- | +| Frontend Plugin | [`@backstage/plugin-techdocs`][techdocs/frontend] | +| Backend Plugin | [`@backstage/plugin-techdocs-backend`][techdocs/backend] | +| CLI (for local development and generating docs) | [`@techdocs/cli`][techdocs/cli] | +| Docker Container (for generating docs) | [`techdocs-container`][techdocs/container] | -[getting started]: getting-started.md -[concepts]: concepts.md -[creating and publishing documentation]: creating-and-publishing.md -[faq]: FAQ.md 'Frequently asked questions' [techdocs/frontend]: https://github.com/backstage/backstage/blob/master/plugins/techdocs [techdocs/backend]: @@ -131,3 +141,6 @@ https://docs.google.com/forms/d/e/1FAIpQLSdn5Vn3MQhCdyYRuW8cMzZkMQF0bFxXYN168gZR This is to gather inputs from you (the Backstage community) which will help us best serve TechDocs adopters and existing users. Your inputs will shape our roadmap and we will share it in the open. + +For any other general queries, reach out to us in the `#docs-like-code` channel +of our [Discord chatroom](https://github.com/backstage/backstage#community). diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index f502b3836e..92cd78b071 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -21,7 +21,7 @@ looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to render on the page in TechDocs/Backstage. The static files consist of HTML, CSS and Images generated by MkDocs. We remove -all the Javascript before adding them to Backstage for security reasons. And +all the JavaScript before adding them to Backstage for security reasons. And there are some additional techdocs metadata JSON files that TechDocs needs to render a site. @@ -50,8 +50,8 @@ built. We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We recommend using a CI/CD pipeline with the repository that has a dedicated -step/job to build docs for TechDocs. The generated static files are then stored -in a cloud storage solution of your choice. +step/job to generate docs for TechDocs. The generated static files are then +stored in a cloud storage solution of your choice. [Track progress here](https://github.com/backstage/backstage/issues/3096). Similar to how it is done in the Basic setup, the TechDocs Reader requests @@ -59,11 +59,11 @@ Similar to how it is done in the Basic setup, the TechDocs Reader requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -We will provide instructions, scripts and/or templates (e.g. GitHub actions) to -build docs in your CI/CD system. +We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to +generate docs in your CI/CD system. [Track progress here.](https://github.com/backstage/backstage/issues/3400) You -will be able to use `techdocs-cli` to build docs and publish the generated docs -site files to your cloud storage system. +will be able to use `techdocs-cli` to generate docs and publish the generated +docs site files to your cloud storage system. Note about caching: We have noticed internally that some storage providers can be quite slow, which is why we are recommending a cache that sits between the @@ -120,8 +120,22 @@ docs site in real-time?** A: Generating the content from Markdown on the fly is not optimal (although that is how the basic out-of-the-box setup is implemented). Storage solutions act as a cache for the generated static content. TechDocs is also currently built on -MkDocs which does not allow us to build docs per-page, so we would have to build -all docs for a entity on every request. +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 @@ -141,13 +155,12 @@ Status of all the features mentioned above. **In place ✅** - Basic setup with techdocs-backend file server as storage. +- Basic setup with cloud storage solution. +- `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 🚧** -- Basic setup with cloud storage solution. - **Not implemented yet ❌** -- `techdocs-cli` is able to generate docs in CI/CD environment. -- `techdocs-cli` is able to publish docs site to any storage. - `techdocs-backend` integration with Backstage access control management. diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index 1675c64288..408f092ebd 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -1,22 +1,52 @@ --- id: concepts title: Concepts -description: Documentation on concepts that are introduced with -Spotify's docs-like-code solution in Backstage +# prettier-ignore +description: Documentation on concepts that are introduced with Spotify's docs-like-code solution in Backstage --- This page describes concepts that are introduced with Spotify's docs-like-code solution in Backstage. -### TechDocs Core Plugin +## Generating TechDocs Steps -The TechDocs Core Plugin is an [MkDocs](https://www.mkdocs.org/) plugin created -as a wrapper around multiple MkDocs plugins and Python Markdown extensions to -standardize the configuration of MkDocs used for TechDocs. +### TechDocs Preparer -[TechDocs Core](https://github.com/backstage/mkdocs-techdocs-core) +Preparing is the first step of generating documentation for an entity. It +fetches the source markdown files from the source code hosting provider (GitHub, +GitLab, etc.) and passes the files to the generator for next steps. -### TechDocs container +There are two kinds of preparers available - + +1. Common Git Preparer - Uses `git clone` on any repository url. +2. Url Reader - Uses source code hosting provider's API to download files. + (Faster and recommended) + +### TechDocs Generator + +Generating is the second step after preparing the markdown source files. This +step either runs the TechDocs container (defined below) or runs `mkdocs` CLI to +generate static HTML files and its assets. + +### TechDocs Publisher + +Publishing is the third and final step after preparing and generating docs. +TechDocs Publisher uploads the generated files to a storage. + +The `techdocs-backend` plugin currently comes with two publishers - Google Cloud +Storage and Local Filesystem. You can configure them in your Backstage app. +[See here](./configuration.md). + +A TechDocs publisher is responsible for two things (two-way communication +between `techdocs-backend` and the storage) + +1. Publish generated static files to a storage (Configured by + `techdocs.builder`) +2. Read files from the storage when users visit a TechDocs site + +[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) + +## TechDocs Container The TechDocs container is a Docker container available at [DockerHub](https://hub.docker.com/r/spotify/techdocs). It builds static HTML @@ -25,16 +55,15 @@ MkDocs. [TechDocs Container](https://github.com/backstage/techdocs-container) -### TechDocs publisher +## TechDocs Core Plugin -The `techdocs-backend` plugin currently comes with one publisher - -`LocalPublish`. +The TechDocs Core Plugin is an [MkDocs](https://www.mkdocs.org/) plugin created +as a wrapper around multiple MkDocs plugins and Python Markdown extensions to +standardize the configuration of MkDocs used for TechDocs. -[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) +[TechDocs Core](https://github.com/backstage/mkdocs-techdocs-core) -More standalone publishers will come in the near future... - -### TechDocs CLI +## TechDocs CLI The TechDocs CLI was created to make it easy to write, generate and preview documentation for publishing. Currently it mostly acts as a wrapper around the @@ -43,7 +72,7 @@ container. [TechDocs CLI](https://github.com/backstage/techdocs-cli) -### TechDocs Reader +## TechDocs Reader Documentation generated by TechDocs is generated as static HTML sites. The TechDocs Reader was therefore created to be able to integrate pre-generated HTML @@ -55,7 +84,7 @@ TechDocs widgets for a customized full-featured TechDocs experience. [TechDocs Reader](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/reader/README.md) -### Transformers +## Transformers Transformers are different pieces of functionality used inside the TechDocs Reader. The reason why transformers were introduced was to provide a way to diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md new file mode 100644 index 0000000000..1580abe69e --- /dev/null +++ b/docs/features/techdocs/configuration.md @@ -0,0 +1,87 @@ +--- +id: configuration +title: TechDocs Configuration Options +# prettier-ignore +description: Reference documentation for configuring TechDocs using app-config.yaml +--- + +Using the `app-config.yaml` in the Backstage app, you can configure TechDocs +using several options. This page serves as a reference to all the available +configuration options for TechDocs. + +```yaml +# File: app-config.yaml + +techdocs: + # 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 + # into Docker in Docker situation. Read more here + # https://backstage.io/docs/features/techdocs/getting-started#disable-docker-in-docker-situation-optional + + generators: + techdocs: 'docker' + + # techdocs.builder can be either 'local' or 'external. + # If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage + # and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture. + # If builder is set to 'external', techdocs-backend will only fetch the docs and will NOT try to generate and publish. In this case of 'external', + # we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository). This is the "Recommended" setup of + # the architecture. Read more here https://backstage.io/docs/features/techdocs/architecture + + builder: 'local' + + # techdocs.publisher is used to configure the Storage option, whether you want to use the local filesystem to store generated docs + # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. + + publisher: + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (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: + # (Required) Cloud Storage Bucket Name + bucketName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage bucket. + # If missing, GOOGLE_APPLICATION_CREDENTIALS environment variable will be used. + # https://cloud.google.com/docs/authentication/production + credentials: + $file: '/path/to/google_application_credentials.json' + + # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. + + awsS3: + # (Required) AWS S3 Bucket Name + bucketName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage bucket. + # If not set, environment variables or aws config file will be used to authenticate. + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + credentials: + accessKeyId: + $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + secretAccessKey: + $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + + # (Optional) AWS Region of the bucket. + # If not set, AWS_REGION environment variable or aws config file will be used. + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + region: + $env: AWS_REGION +``` 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/getting-started.md b/docs/features/techdocs/getting-started.md index e51c6a122e..e1ddfd23f9 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -1,7 +1,7 @@ --- id: getting-started title: Getting Started -description: Getting Started Guidelines +description: Getting Started Documentation --- TechDocs functions as a plugin to Backstage, so you will need to use Backstage @@ -10,50 +10,147 @@ to use TechDocs. If you haven't setup Backstage already, start [here](../../getting-started/index.md). -## Installing TechDocs +> If you used `npx @backstage/create-app`, TechDocs may already be present. +> +> You should skip to [`Setting the Configuration`](#setting-the-configuration) +> below. -TechDocs is provided with the Backstage application by default. If you want to -set up TechDocs manually, keep following the instructions below. - -### Adding the package +## Adding TechDocs frontend plugin The first step is to add the TechDocs plugin to your Backstage application. -Navigate to your new Backstage application folder: - -```bash -cd hello-world/ -``` - -Then navigate to your `packages/app` folder to install TechDocs: +Navigate to your new Backstage application directory. And then to your +`packages/app` directory, and install the `@backstage/plugin-techdocs` package. ```bash +cd my-backstage-app/ cd packages/app yarn add @backstage/plugin-techdocs ``` -After a short while, the TechDocs plugin should be successfully installed. +Once the package has been installed, you need to import the plugin in your app. -Next, you need to set up some basic configuration. Enter the following command: - -```bash -yarn install -``` - -Add this to `packages/app/src/plugins.ts`: +Add the following to `packages/app/src/plugins.ts`: ```typescript export { plugin as TechDocs } from '@backstage/plugin-techdocs'; ``` -### Setting the configuration +Now let us embed the TechDocs router in our main Backstage frontend router. In +`packages/app/src/App.tsx`, import the TechDocs router and add the following to +`AppRoutes`: -TechDocs allows for configuration of the docs storage URL through your -`app-config.yaml` file. We provide two different values to be configured, -`requestUrl` and `storageUrl`. The `requestUrl` is what the reader will request -its data from, and `storageUrl` is where the backend can find the stored -documentation. +```tsx +import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -The default storage and request URLs: +// ... + +const AppRoutes = () => { + + // ... other plugin routes + } /> + ; +}; +``` + +That's it! But now, we need the TechDocs Backend plugin for the frontend to +work. + +## Adding TechDocs Backend plugin + +Navigate to `packages/backend` of your Backstage app, and install the +`@backstage/plugin-techdocs-backend` package. + +```bash +cd my-backstage-app/ +cd packages/backend +yarn add @backstage/plugin-techdocs-backend +``` + +Create a file called `techdocs.ts` inside `packages/backend/src/plugins/` and +add the following + +```typescript +import { + createRouter, + Preparers, + Generators, + Publisher, +} from '@backstage/plugin-techdocs-backend'; +import { PluginEnvironment } from '../types'; +import Docker from 'dockerode'; + +export default async function createPlugin({ + logger, + config, + discovery, + reader, +}: PluginEnvironment) { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); + + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + + return await createRouter({ + preparers, + generators, + publisher, + dockerClient, + logger, + config, + discovery, + }); +} +``` + +You may need to install the `dockerode` package. But you may already have it in +your backend since [Scaffolder plugin](../software-templates/index.md) also uses +it. + +See [Concepts](concepts.md) and [TechDocs Architecture](architecture.md) to +learn more about how preparers, generators and publishers work. + +Final step is to import the techdocs backend plugin in Backstage app backend. +Add the following to your `packages/backend/src/index.ts`: + +```typescript +import techdocs from './plugins/techdocs'; + +// .... main should already be present. +async function main() { + // ... other backend plugin envs + const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + + // ... other backend plugin routes + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); +} +``` + +That's it! TechDocs frontend and backend have now been added to your Backstage +app. Now let us tweak some configurations to suit your needs. + +## Setting the configuration + +**See [TechDocs Configuration Options](configuration.md) for complete +configuration reference.** + +### Setting TechDocs URLs ```yaml techdocs: @@ -61,22 +158,59 @@ techdocs: requestUrl: http://localhost:7000/api/techdocs/ ``` -If you want `techdocs-backend` to manage building and publishing, you want -`requestUrl` to point to the default value (or wherever `techdocs-backend` is -hosted). `storageUrl` should be where your publisher publishes your docs. Using -the default `LocalPublish` that is the default value. +`requestUrl` is used by TechDocs frontend plugin to discover techdocs-backend +endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which +acts as a middleware between TechDocs and the storage (where the static +generated docs site are stored). These default values should mostly work for +you. These options will soon be optional to set. -If you have a setup where you are not using `techdocs-backend` for managing -building and publishing of your documentation, you want to change the -`requestUrl` to point to your storage. In this case `storageUrl` is not -required. +### Should TechDocs Backend generate docs? -### Disable Docker in Docker situation (Optional) +```yaml +techdocs: + storageUrl: http://localhost:7000/api/techdocs/static/docs + requestUrl: http://localhost:7000/api/techdocs/ + builder: 'local' +``` -The TechDocs backend plugin runs a docker container with mkdocs installed to +Set `techdocs.builder` to `'local'` if you want your TechDocs Backend to be +responsible for generating documentation sites. If set to `'external'`, +Backstage will assume that the sites are being generated on each entity's CI/CD +pipeline, and are being stored in a storage somewhere. + +When `techdocs.builder` is set to `'external'`, TechDocs becomes more or less a +read-only experience where it serves static files from a storage containing all +the generated documentation. Read more in the "Basic" and "Recommended" setup of +TechDocs [here](architecture.md) + +### Choosing storage (publisher) + +TechDocs needs to know where to store generated documentation sites and where to +fetch the sites from. This is managed by a +[Publisher](./concepts.md#techdocs-publisher). Examples: Google Cloud Storage, +Amazon S3, or local filesystem of Backstage server. + +It is okay to use the local filesystem in a "Basic" setup when you are trying +out Backstage for the first time. Using Cloud Storage is documented +[here](./using-cloud-storage.md). + +```yaml +techdocs: + storageUrl: http://localhost:7000/api/techdocs/static/docs + requestUrl: http://localhost:7000/api/techdocs/ + builder: 'local' + publisher: + type: 'local' +``` + +### Disabling Docker in Docker situation (Optional) + +You can skip this if your `techdocs.builder` is set to `'external'`. + +The TechDocs Backend plugin runs a docker container with mkdocs installed to generate the frontend of the docs from source files (Markdown). If you are deploying Backstage using Docker, this will mean that your Backstage Docker -container will try to run another Docker container for TechDocs backend. +container will try to run another Docker container for TechDocs Backend. To avoid this problem, we have a configuration available. You can set a value in your `app-config.yaml` that tells the techdocs generator if it should run the @@ -108,23 +242,13 @@ the dependencies. For example, we want `Markdown` version to be You can also explicitly install `Markdown==3.2.2` after installing all other Python packages. -## Run Backstage locally +## Running Backstage locally -Change folder to `/packages/backend` and run the -following command: +Start the frontend and the backend app by +[running backstage locally](../../getting-started/running-backstage-locally.md). -```bash -yarn start -``` - -Open a new command line window. Change directory to your Backstage application -root and run the following command: - -```bash -yarn start -``` - -Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/). +Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/) +to see all your documentation sites. ## Additional reading diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..21206dede5 --- /dev/null +++ b/docs/features/techdocs/using-cloud-storage.md @@ -0,0 +1,197 @@ +--- +id: using-cloud-storage +title: Using Cloud Storage for TechDocs generated files +description: Using Cloud Storage for TechDocs generated files +--- + +In the [TechDocs architecture](./architecture.md) you have the option to choose +where you want to store the Generated static files which TechDocs uses to render +documentation. In both the "Basic" and "Recommended" setup, you can add cloud +storage providers like Google GCS, Amazon AWS S3, etc. By default, TechDocs uses +the local filesystem of the `techdocs-backend` plugin in the "Basic" setup. And +in the recommended setup, having one of the cloud storage is a prerequisite. +Read more on the TechDocs Architecture documentation page. + +On this page you can read how to enable them. + +## Configuring Google GCS Bucket with TechDocs + +Follow the +[official Google Cloud documentation](https://googleapis.dev/nodejs/storage/latest/index.html#quickstart) +for the latest instructions on the following steps involving GCP. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'googleGcs'`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' +``` + +**2. Create a GCS Bucket** + +Create a dedicated Google Cloud Storage bucket for TechDocs sites. +techdocs-backend will publish documentation to this bucket. TechDocs will fetch +files from here to serve documentation in Backstage. Note that the bucket names +are globally unique. + +Set the config `techdocs.publisher.googleGcs.bucketName` in your +`app-config.yaml` to the name of the bucket you just created. + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**3a. (Recommended) Authentication using environment variable** + +The GCS Node.js client will automatically use the environment variable +`GOOGLE_APPLICATION_CREDENTIALS` to authenticate with Google Cloud. It might +already be set in Compute Engine, Google Kubernetes Engine, etc. Read +https://cloud.google.com/docs/authentication/production for more details. + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. + +Create a new Service Account and a key associated with it. In roles of the +service account, use "Storage Admin". + +If you want to create a custom role, make sure to include both `get` and +`create` permissions for both "Objects" and "Buckets". See +https://cloud.google.com/storage/docs/access-control/iam-permissions + +A service account can have many keys. Open your newly created account's page (in +IAM & Admin console), and create a new key. Use JSON format for the key. + +A `.json` file will be downloaded. This is the secret +key TechDocs will use to make API calls. Make it available in your Backstage +server and/or your local development server and set it in the app config +`techdocs.publisher.googleGcs.credentials`. + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' + credentials: + $file: '/path/to/google_application_credentials.json' +``` + +Note: If you are finding it difficult to make the file +`google_application_credentials.json` available on a server, you could use the +file's content and set as an environment variable. And then use + +```yaml +techdocs: + publisher: + type: 'googleGcs' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' + credentials: + $env: GOOGLE_APPLICATION_CREDENTIALS +``` + +**4. That's it!** + +Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to +store and read the static generated documentation files. + +## Configuring AWS S3 Bucket with TechDocs + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'awsS3'`. + +```yaml +techdocs: + publisher: + type: 'awsS3' +``` + +**2. Create an S3 Bucket** + +Create a dedicated AWS S3 bucket for the storage of TechDocs sites. +[Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). + +TechDocs will publish documentation to this bucket and will fetch files from +here to serve documentation in Backstage. Note that the bucket names are +globally unique. + +Set the config `techdocs.publisher.awsS3.bucketName` in your `app-config.yaml` +to the name of the bucket you just created. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**3a. (Recommended) Setup authentication the AWS way, using environment +variables** + +You should follow the +[AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). + +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/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index 042d8964ca..64b9e772d1 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -11,7 +11,7 @@ add an existing plugin to it. We are using the [CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md) plugin in this example. -1. Add the plugin's NPM package to the repo: +1. Add the plugin's npm package to the repo: ```bash yarn add @backstage/plugin-circleci diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 6aef4c48c1..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 @@ -87,7 +103,7 @@ You may encounter the following error message: Couldn't find any versions for "file-saver" that matches "eligrey-FileSaver.js-1.3.8.tar.gz-art-external" ``` -This is likely because you have a globally configured NPM proxy, which breaks +This is likely because you have a globally configured npm proxy, which breaks the installation of the `material-table` dependency. This is a known issue and being worked on in `material-table`, but for now you can work around it using the following: diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index cfd2e903d9..6436bdb195 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -4,19 +4,79 @@ title: Other description: Documentation on different ways of Deployment --- -## Deploying Locally +## Docker -### Try on Docker +Here we have an example Dockerfile that you can use to build everything together +in one container. This Dockerfile uses multi-stage builds, and a +`backend:bundle` command from the CLI. -Run the following commands if you have Docker environment +It also provides caching on the `yarn install`'s so that you don't have to do it +unless absolutely necessary. -```bash -$ yarn install -$ yarn docker-build -$ docker run --rm -it -p 7000:7000 -e APP_ENV=production -e NODE_ENV=development example-backend:latest +> Note: This Dockerfile assumes that you're running SQLite, or your +> configuration is setup to connect to an external PostgreSQL Database. + +```Dockerfile +# Stage 1 - Create yarn install skeleton layer +FROM node:14-buster AS packages + +WORKDIR /app +COPY package.json yarn.lock ./ + +COPY packages packages + +# Uncomment this line if you have a local plugins folder +# COPY plugins plugins + +RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf + +# Stage 2 - Install dependencies and build packages +FROM node:14-buster AS build + +WORKDIR /app +COPY --from=packages /app . + +RUN yarn install --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +COPY . . + +RUN yarn tsc +RUN yarn --cwd packages/backend backstage-cli backend:bundle --build-dependencies + +# Stage 3 - Build the actual backend image and install production dependencies +FROM node:14-buster + +WORKDIR /app + +# Copy from build stage +COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/skeleton.tar.gz ./ +RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz + +RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +COPY --from=build /app/packages/backend/dist/bundle.tar.gz . +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz + +COPY app-config.yaml app-config.production.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"] ``` -Then open http://localhost/ on your browser. +You can add the Dockerfile to the root of your project, and run the following to +build the container under a specified tag. + +```sh +$ docker build -t example-deployment . +``` + +To run the image locally you can run: + +```sh +$ docker run -p -it 7000:7000 example-deployment +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` ## Heroku diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index c5275114ce..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/getting-started/index.md b/docs/getting-started/index.md index 7fc80b625c..16c837d676 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -10,7 +10,7 @@ you're planning to do. Creating a standalone instance makes it simpler to customize the application for your needs whilst staying up to date with the project. You will also depend on -`@backstage` packages from NPM, making the project much smaller. This is the +`@backstage` packages from npm, making the project much smaller. This is the recommended approach if you want to kick the tyres of Backstage or setup your own instance. 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 71612cc5e4..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 @@ -47,55 +47,44 @@ guidelines to get started. ### Ongoing work 🚧 -- **[Plugins for managing micro services end-2-end](https://github.com/backstage/backstage/milestone/14)** - Out of the box Backstage will ship with a set of plugins (Overview, CI, API - and Docs) that will demonstrate how a user can manage a micro service and - follow a change all the way out in production. Completing this work will make - it much easier to see how a plugin can be built that integrates with the - Backstage Service Catalog. +- **[Platform stabilization](https://github.com/backstage/backstage/milestone/19)** - + Stabilize the core of Backstage, including its core features, so that the + platform can be depended on for production use. After this, plugins will + require little-to-no maintenance. -- **[Users and teams](https://github.com/backstage/backstage/issues/1807)** - Ownership is a central concept in Backstage. It should be easy to import your - existing organizational data, such as users and groups/teams, into Backstage. - A user the logs into Backstage should see software components owned by the - team(s) they are in. +- **[Kubernetes plugin for service owners](https://github.com/backstage/backstage/issues/2857)** - + Improve native support for Kubernetes, making it easier for service owners to + see and manage their services running in K8s, regardless if that's locally, in + AWS, GCS, Azure, or elsewhere. -- **[Backstage platform is stable](https://github.com/backstage/backstage/milestone/19)** - - The platform APIs and features are stable and can be depended on for - production use. After this plugins will require little to no maintenance. +- **[Search platform](../features/search/README.md)** - Evolve the basic search + functionality currently available into a platform that **a)** enables search + across the software catalog, TechDocs, and any other information exposed by + plugins, and **b)** supports a variety of search engine technologies. -* **[Improved Kubernetes plugin](https://github.com/backstage/backstage/issues/2857)** - - Native support for Kubernetes, making it easier for developers to see and - manage their services running in k8s. - -- Further improvements to platform documentation. Examples include a Golden Path - for plugin development. +- **[Software Templates V2](https://github.com/backstage/backstage/issues/2771)** - + Expand the templates to make the steps more composable by adding the ability + to add more steps for custom logic, including webhooks and using authorization + from integrations. ### Future work 🔮 -- **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage - deployment available publicly so that people can click around and get a feel - for the product without having to install anything. +- **Golden Path for Plugin Development** - Create an easy, standardized way for + developers to build plugins that will encourage contributions and lead to a + richer ecosystem for everyone. -- **[Global search](https://github.com/backstage/backstage/issues/1499)** - - Extend the basic search available in the Backstage Service Catalog with a - global search experience. Long term this search solution should be extensible, - making it possible for you add custom search results. - -- **[[TechDocs V.2] Stabilization release](https://github.com/backstage/backstage/milestone/17)** - - Platform stability and compatibility improvements. - -- **Additional auth providers** - Backstage should work for most (all!) auth - solutions. Since Backstage can be used by companies regardless of what cloud - (or on prem) you are using we are especially keen to get auth support for - [AWS](https://github.com/backstage/backstage/issues/290), - [Azure](https://github.com/backstage/backstage/issues/348) and others. - -- **[Initial GraphQL API](https://github.com/backstage/backstage/milestone/13)** - - A GraphQL API will open up the rich metadata provided by Backstage in a single +- **[GraphQL API](https://github.com/backstage/backstage/milestone/13)** - A + GraphQL API will open up the rich metadata provided by Backstage in a single query. Plugins can easily query this API as well as extend the model where needed. +- **Inter-Plugin Communication** - **[Under consideration]** Establish more + clearly defined patterns for plugins to communicate. + +- **Improved Access Control** - **[Under consideration]** Provide finer grained + access controls and management for better control of the platform user + experience. + ### Plugins Building and maintaining [plugins](https://backstage.io/plugins) is the work of @@ -110,8 +99,17 @@ Are you missing a plugin for your favorite tool? Please [suggest a new one](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). Chances are that someone will jump in and help build it. +### Community Initiatives 🧑‍🤝‍🧑 (Coming soon) + +- **Backstage Monthly Meetup** - A space for the community to come together to + share and learn about the latest happenings in Backstage. + +- **Backstage Hackathons** - Open to everyone in our Backstage community, a + celebration of you, the project and building awesome things together + ### Completed milestones ✅ +- [Deploy a product demo at `demo.backstage.io`](https://demo.backstage.io) - [Kubernetes plugin - v1](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) - [Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage) - [Backstage Design System 💅](https://backstage.io/blog/2020/09/30/backstage-design-system) diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 567e9dcbff..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 @@ -39,28 +38,28 @@ TL;DR: ## Packages -### [`example-app`](https://github.com/backstage/backstage/tree/master/packages/app/) +### `example-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/app/) This is the `packages/app` package, and it serves as an example as well as utility for local development in the main Backstage repo. Stability: `N/A` -### [`example-backend`](https://github.com/backstage/backstage/tree/master/packages/backend/) +### `example-backend` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend/) This is the `packages/backend` package, and it serves as an example as well as utility for local development in the main Backstage repo. Stability: `N/A` -### [`backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common/) +### `backend-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/backend-common/) A collection of common helpers to be used by both backend plugins, and for constructing backend packages. Stability: `1` -### [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) +### `catalog-client` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) An HTTP client for interacting with the catalog backend. Usable both in frontend and Backend. @@ -68,7 +67,7 @@ and Backend. Stability: `0`. This is a very new addition and we have some immediate changes planned. -### [`catalog-model`](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) +### `catalog-model` [GitHub](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) Contains the core catalog model, and utilities for working with entities. Usable both in frontend and Backend. @@ -77,7 +76,7 @@ Stability: `2`. The catalog model is evolving, but because of the broad usage we want to ensure some stability. -### [`cli`](https://github.com/backstage/backstage/tree/master/packages/cli/) +### `cli` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli/) The main toolchain used for Backstage development. The various CLI commands and options passed to those commands, as well as the environment variables read by @@ -87,27 +86,27 @@ unless it is likely to affect external tooling. Stability: `2` -### [`cli-common`](https://github.com/backstage/backstage/tree/master/packages/cli-common/) +### `cli-common` [GitHub](https://github.com/backstage/backstage/tree/master/packages/cli-common/) Lightweight utilities used by the various Backstage CLIs, not intended for external use. Stability: `N/A` -### [`config`](https://github.com/backstage/backstage/tree/master/packages/config/) +### `config` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config/) Provides the logic and interfaces for reading static configuration. Stability: `2` -### [`config-loader`](https://github.com/backstage/backstage/tree/master/packages/config-loader/) +### `config-loader` [GitHub](https://github.com/backstage/backstage/tree/master/packages/config-loader/) Used to load in static configuration, mainly for use by the CLI and @backstage/backend-common. Stability: `1`. Mainly intended for internal use. -### [`core`](https://github.com/backstage/backstage/tree/master/packages/core/) +### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/) #### Section: React Components @@ -147,51 +146,62 @@ ErrorApi, IdentityApi, the auth APIs, etc. Stability: `1`. Most changes to the core utility APIs will not lead to widespread breaking changes since most apps rely on the default implementations. -### [`core-api`](https://github.com/backstage/backstage/tree/master/packages/core-api/) +### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/) The non-visual parts of @backstage/core. Everything in this packages is re-exported from @backstage/core, and this package should not be used directly. Stability: See @backstage/core -### [`create-app`](https://github.com/backstage/backstage/tree/master/packages/create-app/) +### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) The CLI used to scaffold new Backstage projects. Stability: `2` -### [`dev-utils`](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) +### `dev-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) Provides utilities for developing plugins in isolation. Stability: `0`. This package is largely broken and needs updates. -### [`docgen`](https://github.com/backstage/backstage/tree/master/packages/docgen/) +### `docgen` [GitHub](https://github.com/backstage/backstage/tree/master/packages/docgen/) Internal CLI utility for generating API Documentation. Stability: `N/A` -### [`e2e-test`](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) +### `e2e-test` [GitHub](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) Internal CLI utility for running e2e tests. Stability: `N/A` -### [`storybook`](https://github.com/backstage/backstage/tree/master/packages/storybook/) +### `integration` [GitHub](https://github.com/backstage/backstage/tree/master/packages/integration/) + +Provides shared utilities for managing integrations towards different types of +third party systems. This package is currently internal and its functionality +will likely be exposed via separate APIs in the future. + +Some of the functionality in this package is not available elsewhere yes, so if +it's necessary it can be used, but there will be breaking changes. + +Stability: `0` + +### `storybook` [GitHub](https://github.com/backstage/backstage/tree/master/packages/storybook/) Internal storybook build for publishing stories to https://backstage.io/storybook Stability: `N/A` -### [`test-utils`](https://github.com/backstage/backstage/tree/master/packages/test-utils/) +### `test-utils` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils/) Utilities for writing tests for Backstage plugins and apps. Stability: `2` -### [`test-utils-core`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) +### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) Internal testing utilities that are separated out for usage in @backstage/core-api. All exports are re-exported by @backstage/test-utils. This @@ -199,7 +209,7 @@ package should not be depended on directly. Stability: See @backstage/test-utils -### [`theme`](https://github.com/backstage/backstage/tree/master/packages/theme/) +### `theme` [GitHub](https://github.com/backstage/backstage/tree/master/packages/theme/) The core Backstage MUI theme along with customization utilities. @@ -229,21 +239,21 @@ Any plugin that is not listed below is untracked and can generally be considered unstable with a score of `0`. Open a Pull Request if you want your plugin to be added! -### [`api-docs`](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) +### `api-docs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) Components to discover and display API entities as an extension to the catalog plugin. Stability: `0` -### [`app-backend`](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) +### `app-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) A backend plugin that can be used to serve the frontend app and inject configuration. Stability: `2` -### [`auth-backend`](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) +### `auth-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) A backend plugin that implements the backend portion of the various authentication flows used in Backstage. @@ -256,7 +266,7 @@ Stability: `2` Stability: `1` -### [`catalog`](https://github.com/backstage/backstage/tree/master/plugins/catalog/) +### `catalog` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog/) The frontend plugin for the catalog, with the table and building blocks for the entity pages. @@ -264,7 +274,7 @@ entity pages. Stability: `1`. We're planning some work to overhaul how entity pages are constructed. -### [`catalog-backend`](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) +### `catalog-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) The backend API for the catalog, also exposes the processing subsystem for customization of the catalog. Powers the @backstage/plugin-catalog frontend @@ -278,53 +288,53 @@ Stability: `1`. There are plans to remove and rework some endpoints. Stability: `1`. There are plans to rework parts of the Processor interface. -### [`catalog-graphql`](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) +### `catalog-graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) -Provides the catalog schema and resolvers for the graphql backend. +Provides the catalog schema and resolvers for the GraphQL backend. Stability: `0`. Under heavy development and subject to change. -### [`explore`](https://github.com/backstage/backstage/tree/master/plugins/explore/) +### `explore` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/explore/) A frontend plugin that introduces the concept of exploring internal and external tooling in an organization. Stability: `0`. Only an example at the moment and not customizable. -### [`graphiql`](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) +### `graphiql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. Stability: `1` -### [`graphql`](https://github.com/backstage/backstage/tree/master/plugins/graphql/) +### `graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphql/) A backend plugin that provides Stability: `0`. Under heavy development and subject to change. -### [`kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) +### `kubernetes` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) The frontend component of the Kubernetes plugin, used to browse and visualize Kubernetes resources. Stability: `1`. -### [`kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) +### `kubernetes-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) The backend component of the Kubernetes plugin, used to fetch Kubernetes resources from clusters and associate them with entities in the Catalog. Stability: `1`. -### [`proxy-backend`](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) +### `proxy-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) A backend plugin used to set up proxying to other endpoints based on static configuration. Stability: `1` -### [`register-component`](https://github.com/backstage/backstage/tree/master/plugins/register-component/) +### `register-component` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/register-component/) A frontend plugin that allows the user to register entity locations in the catalog. @@ -332,14 +342,14 @@ catalog. Stability: `0`. This plugin is likely to be replaced by a generic entity import plugin instead. -### [`scaffolder`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) +### `scaffolder` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) The frontend scaffolder plugin where one can browse templates and initiate scaffolding jobs. Stability: `1` -### [`scaffolder-backend`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) +### `scaffolder-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) The backend scaffolder plugin that provides an implementation for templates in the catalog. @@ -347,35 +357,35 @@ the catalog. Stability: `1`. There is planned work to rework the scaffolder in https://github.com/backstage/backstage/issues/2771. -### [`tech-radar`](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) +### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) Visualize the your company's official guidelines of different areas of software development. Stability: `0` -### [`techdocs`](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) +### `techdocs` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) The frontend component of the TechDocs plugin, used to browse technical documentation of entities. Stability: `1` -### [`techdocs-backend`](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) +### `techdocs-backend` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) The backend component of the TechDocs plugin, used to transform and serve TechDocs. Stability: `0` -### [`user-settings`](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) +### `user-settings` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) A frontend plugin that provides a page where the user can tweak various settings. Stability: `1` -### [`welcome`](https://github.com/backstage/backstage/tree/master/plugins/welcome/) +### `welcome` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/welcome/) A plugin that can be used to welcome the user to Backstage. 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/index.md b/docs/plugins/index.md index dcef3f995c..32d523b2f5 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -33,4 +33,4 @@ that someone else will pick up the work. If your plugin isn't supposed to live as a standalone page, but rather needs to be presented as a part of a Service Catalog (e.g. a separate tab or a card on an "Overview" tab), then check out -[the instruction](integrating-plugin-into-service-catalog.md). on how to do it. +[the instruction](integrating-plugin-into-service-catalog.md) on how to do it. diff --git a/docs/plugins/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 7eab61891e..06fae0533e 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -1,16 +1,16 @@ --- id: publishing title: Publishing -description: Documentation on Publishing NPM packages +description: Documentation on Publishing npm packages --- -## NPM +## npm -NPM packages are published through CI/CD in the -[.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) +npm packages are published through CI/CD in the +[`.github/workflows/master.yml`](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) workflow. Every commit that is merged to master will be checked for new versions of all public packages, and any new versions will automatically be published to -NPM. +npm. ### Creating a new release diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index e60b5fa8b7..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,34 +210,40 @@ There are four things to test for in a utility function: > Handle Invalid Input (handle thrown errors): - it('Throws an error on improper arguments', () => { - expect(() => { - ellipsis(); - }).toThrowError('Expected \'text\' to be defined'); - }); +```js +it('Throws an error on improper arguments', () => { + expect(() => { + ellipsis(); + }).toThrowError("Expected 'text' to be defined"); +}); +``` > Verify default input arguments: - it('Works with defaults', () => { - expect(ellipsis('Hello world', 3)).toBe('Hel...'); - expect(ellipsis('', 3)).toBe(''); - expect(ellipsis('H', 3)).toBe('H'); - expect(ellipsis('Hello', 5)).toBe('Hello'); - }); +```js +it('Works with defaults', () => { + expect(ellipsis('Hello world', 3)).toBe('Hel...'); + expect(ellipsis('', 3)).toBe(''); + expect(ellipsis('H', 3)).toBe('H'); + expect(ellipsis('Hello', 5)).toBe('Hello'); +}); +``` > Verify output for expected input arguments: This is especially true for edge cases! - it('Works with midCharIx', () => { - expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); - expect(ellipsis('', 3, 6)).toBe(''); - expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); - }); +```js +it('Works with midCharIx', () => { + expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); + expect(ellipsis('', 3, 6)).toBe(''); + expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); +}); +``` ## Non-React Classes -Testing a Javascript object which is _not_ a React component follows a lot of +Testing a JavaScript object which is _not_ a React component follows a lot of the same principles as testing objects in other languages. ### API Testing Principles @@ -243,7 +252,7 @@ Testing an API involves verifying four things: 1. Invalid inputs are caught before being sent to the server. 2. Valid inputs translate into a valid browser request. -3. Server response is translated into an expected Javascript object. +3. Server response is translated into an expected JavaScript object. 4. Server errors are handled gracefully. ### Mocking API Calls @@ -372,4 +381,4 @@ IDE. In most cases, we have found that using `console.log` works well. Note: if your console.logs are not being displayed, focus your specific unit -test from the command line by running them like so `yarn test-react MyTest`. +test from the command line by running them like so `yarn test MyTest`. diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index bcea80e26b..622c085291 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -27,7 +27,7 @@ To inspect the state of a feature flag inside your plugin, you can use the `FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md 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 c25e087e57..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,17 +32,13 @@ 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.
 
 - [`docs/`](https://github.com/backstage/backstage/tree/master/docs) - This is
-  where we keep all of our documentation Markdown files. These ends up on
-  http://backstage.io/docs. Just keep in mind that changes to the
+  where we keep all of our documentation Markdown files. These end up on
+  https://backstage.io/docs. Just keep in mind that changes to the
   [`sidebars.json`](https://github.com/backstage/backstage/blob/master/microsite/sidebars.json)
   file may be needed as sections are added/removed.
 
@@ -161,7 +157,7 @@ are separated out into their own folder, see further down.
 
 - [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) -
   Uses the
-  [Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API)
+  [TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API)
   to read out definitions and generate documentation for it.
 
 - [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) -
@@ -169,11 +165,19 @@ are separated out into their own folder, see further down.
   packages, publish them, created a new app, and the run it. CI uses this for
   e2e-tests.
 
+- [`integration/`](https://github.com/backstage/backstage/tree/master/packages/integration) -
+  Common functionalities of integrations like GitHub, GitLab, etc.
+
 - [`storybook/`](https://github.com/backstage/backstage/tree/master/packages/storybook) -
   This folder contains only the storybook config. Stories are within the core
   package. The Backstage Storybook is found
   [here](https://backstage.io/storybook)
 
+- [`techdocs-common/`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) -
+  Common functionalities for TechDocs, to be shared between
+  [techdocs-backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend)
+  plugin and [techdocs-cli](https://github.com/backstage/techdocs-cli).
+
 - [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core)
 
 - [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils)
diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md
index 664d4b77d3..adefdfa73f 100644
--- a/docs/tutorials/journey.md
+++ b/docs/tutorials/journey.md
@@ -22,7 +22,7 @@ music and wants to have a theme tune for every service in Backstage.
 
 Sam built a Spotify plugin for Backstage that allows service owners to define a
 theme tune for their service. The theme tune plays whenever a user visits the
-service page in Backstage. The plugin is published to NPM and available for any
+service page in Backstage. The plugin is published to npm and available for any
 organization to easily install and add to their Backstage installation.
 
 # 1. A New Plugin
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
index 1dcca9d7e0..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 216ff9659f..6208fbe30d 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -59,7 +59,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; ``` Simple! The App will reload with your changes automatically. You should now see -a github icon displayed in the sidebar. Clicking that will link to our new +a GitHub icon displayed in the sidebar. Clicking that will link to our new plugin. And now, the API fun begins. # The Identity @@ -72,8 +72,7 @@ Our first modification will be to extract information from the Identity API. ```tsx // Add identityApiRef to the list of imported from core -import { identityApiRef } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; +import { identityApiRef, useApi } from '@backstage/core'; ``` 3. Adjust the ExampleComponent from inline to block @@ -81,13 +80,13 @@ import { useApi } from '@backstage/core-api'; _from inline:_ ```tsx -const ExampleComponent: FC<{}> = () => ( ... ) +const ExampleComponent = () => ( ... ) ``` _to block:_ ```tsx -const ExampleComponent: FC<{}> = () => { +const ExampleComponent = () => { return ( ... @@ -135,7 +134,7 @@ changes, let's start by wiping this component clean. 1. Replace everything in the file with the following: ```tsx -import React, { FC } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { @@ -143,11 +142,11 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { return
Nothing to see yet
; }; @@ -223,7 +222,7 @@ type DenseTableProps = { viewer: Viewer; }; -export const DenseTable: FC = ({ viewer }) => { +export const DenseTable = ({ viewer }: DenseTableProps) => { const columns: TableColumn[] = [ { title: 'Name', field: 'name' }, { title: 'Created', field: 'createdAt' }, diff --git a/microsite/blog/2020-03-16-announcing-backstage.md b/microsite/blog/2020-03-16-announcing-backstage.md index 5824b76a57..5094de7b37 100644 --- a/microsite/blog/2020-03-16-announcing-backstage.md +++ b/microsite/blog/2020-03-16-announcing-backstage.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md index f4f62e2cf7..2c2b81d64d 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.md +++ b/microsite/blog/2020-03-18-what-is-backstage.md @@ -1,6 +1,6 @@ --- title: What the heck is Backstage anyway? -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-04-06-lighthouse-plugin.md b/microsite/blog/2020-04-06-lighthouse-plugin.md index b8fd68e783..dcdb4b78e5 100644 --- a/microsite/blog/2020-04-06-lighthouse-plugin.md +++ b/microsite/blog/2020-04-06-lighthouse-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Lighthouse for Backstage -author: Paul Marbach +author: Paul Marbach, Spotify authorURL: http://twitter.com/fastfrwrd authorImageURL: https://pbs.twimg.com/profile_images/1224058798958088192/JPxS8uzR_400x400.jpg --- diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index 85f40dd9ea..65102c919f 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -1,6 +1,6 @@ --- title: How to quickly set up Backstage -author: Marcus Eide +author: Marcus Eide, Spotify authorURL: https://github.com/marcuseide authorImageURL: https://secure.gravatar.com/avatar/20223f1e03673c7c1e6282fbebaf6942 --- diff --git a/microsite/blog/2020-05-14-tech-radar-plugin.md b/microsite/blog/2020-05-14-tech-radar-plugin.md index 80c1eb8b5a..b78cf9004f 100644 --- a/microsite/blog/2020-05-14-tech-radar-plugin.md +++ b/microsite/blog/2020-05-14-tech-radar-plugin.md @@ -1,6 +1,6 @@ --- title: Introducing Tech Radar for Backstage -author: Bilawal Hameed +author: Bilawal Hameed, Spotify authorURL: http://twitter.com/bilawalhameed authorImageURL: https://avatars0.githubusercontent.com/bih --- diff --git a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md index 68e3f903c7..3e3d8a537a 100644 --- a/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md +++ b/microsite/blog/2020-05-14-weaveworks-covid-19-app-uses-backstage-ui.md @@ -1,6 +1,6 @@ --- title: Weaveworks’ COVID-19 app uses Backstage UI -author: Jeff Feng +author: Jeff Feng, Spotify authorURL: https://github.com/fengypants authorImageURL: https://avatars2.githubusercontent.com/u/46946747 --- diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 103750dcc6..520a2a5f10 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -1,6 +1,6 @@ --- title: Starting Phase 2: The Service Catalog -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg --- diff --git a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md index e50d7a6d47..4519f16d96 100644 --- a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md +++ b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md @@ -1,6 +1,6 @@ --- title: Backstage Service Catalog released in alpha -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund image: https://backstage.io/blog/assets/6/header.png --- diff --git a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md index 9d41a21d05..ce778c9dc2 100644 --- a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md +++ b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md @@ -1,6 +1,6 @@ --- title: How to enable authentication in Backstage using Passport -author: Lee Mills +author: Lee Mills, Spotify authorURL: https://github.com/leemills83 authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4 --- diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index bd6ae8eeeb..afda20e499 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -1,6 +1,6 @@ --- title: Announcing Backstage Software Templates -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index ceab17d7be..98fbad57c5 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -1,12 +1,12 @@ --- title: Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage -author: Gary Niemen +author: Gary Niemen, Spotify authorURL: https://github.com/garyniemen --- Since we [open sourced Backstage](https://backstage.io/blog/2020/03/16/announcing-backstage), one of the most requested features has been for a technical documentation plugin. Well, good news. The first open source version of TechDocs is here. Now let’s start collaborating and making it better, together. - diff --git a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md index 8fc459cb62..48a67e878c 100644 --- a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md +++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md @@ -1,6 +1,6 @@ --- title: Backstage has been accepted into the CNCF Sandbox -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-09-30-backstage-design-system.md b/microsite/blog/2020-09-30-backstage-design-system.md index a1d087f755..fc227ea5d3 100644 --- a/microsite/blog/2020-09-30-backstage-design-system.md +++ b/microsite/blog/2020-09-30-backstage-design-system.md @@ -1,6 +1,6 @@ --- title: How to design for Backstage (even if you’re not a designer) -author: Kat Zhou +author: Kat Zhou, Spotify authorURL: http://twitter.com/katherinemzhou --- diff --git a/microsite/blog/2020-09-30-plugin-marketplace.md b/microsite/blog/2020-09-30-plugin-marketplace.md index 10928112b8..f4e9b749e2 100644 --- a/microsite/blog/2020-09-30-plugin-marketplace.md +++ b/microsite/blog/2020-09-30-plugin-marketplace.md @@ -1,6 +1,6 @@ --- title: The Plugin Marketplace is open -author: Stefan Ålund +author: Stefan Ålund, Spotify authorURL: https://twitter.com/stalund --- diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index 15647f8418..d265698694 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -1,12 +1,12 @@ --- title: New Cost Insights plugin: The engineer’s solution to taming cloud costs -author: Janisa Anandamohan +author: Janisa Anandamohan, Spotify authorURL: https://twitter.com/janisa_a --- How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources. - + Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage. diff --git a/microsite/blog/2020-12-22-stability-index.md b/microsite/blog/2020-12-22-stability-index.md new file mode 100644 index 0000000000..95074a13a2 --- /dev/null +++ b/microsite/blog/2020-12-22-stability-index.md @@ -0,0 +1,42 @@ +--- +title: Announcing the Backstage Stability Index +author: Patrik Oldsberg, Spotify +authorURL: https://github.com/Rugvip +--- + +**TL;DR** Backstage is heading out of alpha and moving onto the path to stable releases and an eventual version 1.0. As the community and ecosystem continue to grow at an increasing rate, we want to provide a solid foundation for everyone building things in, with, and around Backstage. So, today we’re introducing the [Stability Index](https://backstage.io/docs/overview/stability-index) — a simple way to find out how likely (or unlikely) a specific package or plugin inside Backstage might be updated with major changes. By indicating the reliability of key features and APIs, this quick reference will help contributors and adopters better plan and coordinate their development efforts going forward. + +![Animation cycling between stability index scores](assets/2020-12-22/stability-index-hero.gif) + + + +Backstage has a great and growing community of users and contributors. With so much valuable feedback, this inevitably means that sometimes we will go back and revise design decisions that introduce breaking changes. Furthermore, as we continue to ship new features and systems, we’ll continue this cycle of iterating, gathering feedback, and iterating again before reaching a stable API. + +This rapid evolution can create uncertainty around which parts of the project are considered stable and which are not. Regardless of whether you're contributing to the project directly, setting up your own Backstage, or building plugins, you will be using various parts of the project that vary in stability. While many API surfaces of Backstage are reasonably solid and are unlikely to see any big breaking changes, others are more unstable or experimental. + +## The Stability Index + +In order to tackle the problem of uncertainty, and help align contributors, we have recently introduced a Stability Index. Inspired by a [similar concept with the same name in Node.js](https://nodejs.org/docs/latest-v4.x/api/documentation.html#documentation_stability_index), it’s a score assigned to subsets of the project, indicating the level of maturity of the API and the commitment to backwards compatibility. However, because of the current phase of the project, we have used a slightly different implementation. Rather than the score indicating a perceived stability, a higher score is instead a commitment to providing a smoother upgrade path for users, both through better documentation and backwards compatibility. Importantly, the Stability Index does not supersede [semantic versioning](https://semver.org/) (or semver), meaning we will still adhere to semver and only do breaking changes in minor releases as long as we are on 0.x. + +You can find more details about the scores on the [Stability Index](https://backstage.io/docs/overview/stability-index) page, but the following is a TL;DR of the 0–3 scores: + +- **0** — There's a changelog entry. +- **1** — There's a migration guide. +- **2** — Two weeks of deprecation. +- **3** — Three months of deprecation. + +The Stability Index has been tailored for the phase that the project is currently in, and we do not intend to keep it in place in its current form forever. It is, for example, likely that we in the future will increase the deprecation time limits. + +## Staying up to date + +In addition to the Stability Index, we have also recently adopted a weekly release cadence where we cut new releases every Thursday. Along with the two-week deprecation period of many of the API surfaces in Backstage, there is an excellent opportunity to form a routine to update your own Backstage projects either weekly or biweekly. + +In order to make the update process easier, we’ve added a new command to the Backstage CLI that allows you to easily update your project to use the latest Backstage release. The command is called `version:bump` and is executed in the root of your project using the CLI. You can see what it looks like in action below. + +![Output of the `backstage-cli` `version:bump` command](assets/2020-12-22/versions-bump.png) + +## Impact + +The Stability Index has already been active for a couple of weeks, and we have immediately seen an impact on the consideration and care taken when working in parts of the project that we have committed to keeping more stable. Both members of the core maintainer team and contributors from the community have taken care to roll out updates responsibly, with examples such as [PR #3524](https://github.com/backstage/backstage/pull/3524) and [PR #3465](https://github.com/backstage/backstage/pull/3465). + +The core team is working hard to bring more stability to broader parts of Backstage, and engineering it in a way that allows for long-term evolution. There are more learnings to be discovered as we continue evolving Backstage, but we are confident that the Stability Index will allow for a clearer path and a smoother ride for everyone involved! diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md new file mode 100644 index 0000000000..a0d11a580f --- /dev/null +++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md @@ -0,0 +1,72 @@ +--- +title: New Backstage feature: Kubernetes for Service owners +author: Matthew Clarke, Spotify +authorURL: https://github.com/mclarke47 +--- + +![Animation of Kubernetes and cloud provider icons becoming the Backstage logo](assets/21-01-12/backstage-k8s-1-hero.gif) + +TLDR; We’re rethinking the Kubernetes developer experience with a new feature: a Kubernetes monitoring tool that’s designed around the needs of service owners, not cluster admins. Now developers can easily check the health of their services no matter how or where those services are deployed — whether it’s on a local host for testing or in production on dozens of clusters around the world. + +And since Backstage uses the native Kubernetes API, the feature works with whichever cloud provider (AWS, Azure, GCP, etc.) or managed service (OpenShift, IBM Cloud, GKE, etc.) you already use. + + + +## The missing link between K8s and your service + +A core feature of Backstage is its service catalog, which aggregates information about software systems together inside a single tool, with a consistent, familiar UI. + +By navigating to a service’s overview page in Backstage, you can see everything you need to know about the service: what it does, its APIs and technical documentation, CI/CD progress — and now detailed information about its presence on Kubernetes clusters. + +## No more context switching + +Kubernetes in Backstage can be configured to search multiple clusters for your services. It will then aggregate them together into a single view. So if you deploy to multiple clusters you will no longer need to switch kubectl contexts to understand the current state of your service. + +![List of deployments in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-2-deployments.png) + +## Automatic error reporting + +Instead of trying different kubectl commands to figure out where an error occurred, Backstage will automatically find and highlight errors in Kubernetes resources that are affecting your service. So you can spend time fixing errors, not hunting for them. + +![Error reporting screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-3-error-reporting.png) + +## Autoscaling limits at a glance + +Backstage also shows you how close your service is to its autoscaling limit. Coming up to a period of high load? Now you will be able to see how your horizontal autoscaling is dealing with it across multiple clusters. + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-4-autoscaling-limits.png) + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-5-autoscaling-limits.png) + +## Pick a cloud, any Cloud + +Since Backstage communicates directly with the Kubernetes API, it’s cloud agnostic — it doesn’t matter how or where you’re running Kubernetes. You’ll always get the same familiar view of your deployments, whether you’re: + +- Deploying to clusters on AWS, Azure, GCP, or another cloud provider +- Using an unmanaged or managed Kubernetes service (like OpenShift, etc.) +- Migrating from one cloud provider or service to another +- Testing on a single local machine or deploying to a dozen clusters in production + +In short: local or global, single or multi-cloud, managed or unmanaged — Backstage always provides a seamless Kubernetes experience for your service owners’ day-to-day development needs. + +## Rethinking the developer experience + +The philosophy behind Backstage is simple: improve developer experience by reducing infrastructure complexity. As popular and widespread as Kubernetes has become, all of the tools to date have been geared toward the needs of cluster admins. These tools add unnecessary complexity to the workflows of the typical developer building, testing, and deploying services. + +We believe Backstage Kubernetes gives developers back control of their services by providing a more focused and consistent experience. Backstage provides a single standard for developers to monitor their Kubernetes deployments, regardless of the underlying cloud infrastructure. + +## Future iterations + +The current focus of Kubernetes in Backstage is Deployments/ReplicaSets/Pods — but we know that not everyone utilizes these. + +As we continue to grow and develop Kubernetes in Backstage with the community, we hope to offer support for Kubernetes resources beyond Deployments and Custom Resource Definitions. You can browse or add open issues for the plugin [here]. + +## Getting started + +We made the Kubernetes plugin a core feature of Backstage. Like Software Templates (scaffolder) and TechDocs, the k8s-plugin is installed with the core app. When you update the app to the latest version and go to the Kubernetes tab of any service, you will be asked to provide your cloud provider credentials. To learn more, including details on configuration and surfacing your Kubernetes components as part of an entity, [read the docs]. + +To contribute or get more information on Kubernetes in Backstage, [join the discussion on Discord]! + +[here]: https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+kubernetes+label%3Ak8s-plugin +[read the docs]: https://backstage.io/docs/features/kubernetes/overview +[join the discussion on discord]: https://discord.gg/MUpMjP2 diff --git a/microsite/blog/assets/2020-12-22/stability-index-hero.gif b/microsite/blog/assets/2020-12-22/stability-index-hero.gif new file mode 100644 index 0000000000..6459d241e2 Binary files /dev/null and b/microsite/blog/assets/2020-12-22/stability-index-hero.gif differ diff --git a/microsite/blog/assets/2020-12-22/versions-bump.png b/microsite/blog/assets/2020-12-22/versions-bump.png new file mode 100644 index 0000000000..2a8d624687 Binary files /dev/null and b/microsite/blog/assets/2020-12-22/versions-bump.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif b/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif new file mode 100644 index 0000000000..7af1a5a305 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png b/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png new file mode 100644 index 0000000000..4a9f2b06fa Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png b/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png new file mode 100644 index 0000000000..29002369d3 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png b/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png new file mode 100644 index 0000000000..7333fc7076 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png b/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png new file mode 100644 index 0000000000..c9be936787 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png differ diff --git a/microsite/core/Components.js b/microsite/core/Components.js index 49e7d7daca..4811b49291 100644 --- a/microsite/core/Components.js +++ b/microsite/core/Components.js @@ -106,9 +106,29 @@ const Breakpoint = ({ narrow, wide }) => ( ); +const Banner = simpleComponent('div', 'Banner', ['hidden']); +Banner.Container = simpleComponent('div', 'Banner__Container'); + +const BannerDismissButton = simpleComponent( + props => ( + + + + ), + 'Banner__DismissButton', +); + +Banner.Dismissable = ({ storageKey, children }) => ( + +); + module.exports = { Block, ActionBlock, Breakpoint, BulletLine, + Banner, }; diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 6ce3c3dd84..1471d7b4d1 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -54,6 +54,12 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} + + + Spotify Engineering Blog + + Spotify for Developers + GitHub +

+ Made with ❤️  at Spotify +

{this.props.config.copyright}

); diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml new file mode 100644 index 0000000000..ca753f682a --- /dev/null +++ b/microsite/data/plugins/argo-cd.yaml @@ -0,0 +1,12 @@ +--- +title: Argo CD +author: roadie.io +authorUrl: https://roadie.io +category: CI/CD +description: View Argo CD status for your projects in Backstage. +documentation: https://roadie.io/backstage/plugins/argo-cd +iconUrl: https://roadie.io/images/logos/argo.png +npmPackageName: '@roadiehq/backstage-plugin-argo-cd' +tags: + - cd + - ci diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml index f325c0df8b..cc3d59d188 100644 --- a/microsite/data/plugins/aws-lambda.yaml +++ b/microsite/data/plugins/aws-lambda.yaml @@ -2,7 +2,7 @@ title: AWS Lambda author: roadie.io authorUrl: https://roadie.io -category: Monitoring +category: Infrastructure description: View AWS Lambda functions for your components in Backstage. documentation: https://roadie.io/backstage/plugins/aws-lambda iconUrl: https://roadie.io/images/logos/lambda.png diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml index eeb9440aac..16c6960898 100644 --- a/microsite/data/plugins/buildkite.yaml +++ b/microsite/data/plugins/buildkite.yaml @@ -2,7 +2,7 @@ title: Buildkite author: roadie.io authorUrl: https://roadie.io -category: CI +category: CI/CD description: View Buildkite CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/buildkite iconUrl: https://roadie.io/images/logos/buildkite.png diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 467da9fb1e..0acad832fa 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -2,7 +2,7 @@ title: CircleCI author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png diff --git a/microsite/data/plugins/cloud-build.yaml b/microsite/data/plugins/cloud-build.yaml index 2b40d02de2..253a935392 100644 --- a/microsite/data/plugins/cloud-build.yaml +++ b/microsite/data/plugins/cloud-build.yaml @@ -2,7 +2,7 @@ title: Google Cloud Build author: Trivago authorUrl: https://www.trivago.com -category: CI +category: CI/CD description: Build, test, and deploy on Google's serverless CI/CD platform. documentation: https://github.com/backstage/backstage/tree/master/plugins/cloudbuild iconUrl: https://avatars2.githubusercontent.com/u/38220399?s=400&v=4 diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml index 8c1ea0be0a..9a1777754a 100644 --- a/microsite/data/plugins/firebase-functions.yaml +++ b/microsite/data/plugins/firebase-functions.yaml @@ -2,7 +2,7 @@ title: Firebase Functions author: roadie.io authorUrl: https://roadie.io/ -category: Monitoring +category: Infrastructure description: View Firebase Functions details for your service in Backstage. documentation: https://roadie.io/backstage/plugins/firebase-functions iconUrl: https://roadie.io/images/logos/firebase.png diff --git a/microsite/data/plugins/fossa.yaml b/microsite/data/plugins/fossa.yaml new file mode 100644 index 0000000000..c49e89c994 --- /dev/null +++ b/microsite/data/plugins/fossa.yaml @@ -0,0 +1,9 @@ +--- +title: FOSSA +author: SDA SE +authorUrl: https://sda.se/ +category: Quality +description: View FOSSA license compliance of your components in Backstage. +documentation: https://github.com/backstage/backstage/blob/master/plugins/fossa/README.md +iconUrl: https://avatars0.githubusercontent.com/u/9543448?s=400&v=4 +npmPackageName: '@backstage/plugin-fossa' diff --git a/microsite/data/plugins/gcp-projects.yaml b/microsite/data/plugins/gcp-projects.yaml index 1c015821b2..7c4b75d2a4 100644 --- a/microsite/data/plugins/gcp-projects.yaml +++ b/microsite/data/plugins/gcp-projects.yaml @@ -2,7 +2,7 @@ title: GCP Project Creator author: Trivago authorUrl: https://www.trivago.com -category: Cloud +category: Infrastructure description: Create, list and manage your Google Cloud Projects. documentation: https://github.com/backstage/backstage/tree/master/plugins/gcp-projects iconUrl: https://avatars1.githubusercontent.com/u/2810941?s=280&v=4 diff --git a/microsite/data/plugins/github-actions.yaml b/microsite/data/plugins/github-actions.yaml index aeccdc9b86..8d5a74d9ce 100644 --- a/microsite/data/plugins/github-actions.yaml +++ b/microsite/data/plugins/github-actions.yaml @@ -2,7 +2,7 @@ title: GitHub Actions author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. documentation: https://github.com/backstage/backstage/tree/master/plugins/github-actions iconUrl: https://avatars2.githubusercontent.com/u/44036562?s=400&v=4 diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml index 1dba3dcfd1..f127f88a3c 100644 --- a/microsite/data/plugins/github-insights.yaml +++ b/microsite/data/plugins/github-insights.yaml @@ -2,7 +2,7 @@ title: GitHub Insights author: roadie.io authorUrl: https://roadie.io -category: Monitoring +category: Source Control Mgmt description: View GitHub Insights for your components in Backstage. documentation: https://roadie.io/backstage/plugins/github-insights iconUrl: https://roadie.io/images/logos/insights.png diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 8e44a4bf56..4172cdff6b 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -2,7 +2,7 @@ title: GitHub Pull Requests author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: Source Control Mgmt description: View GitHub pull requests for your service in Backstage. documentation: https://roadie.io/backstage/plugins/github-pull-requests iconUrl: https://roadie.io/images/logos/github.png diff --git a/microsite/data/plugins/jenkins.yaml b/microsite/data/plugins/jenkins.yaml index 638758d54d..7a8f884221 100644 --- a/microsite/data/plugins/jenkins.yaml +++ b/microsite/data/plugins/jenkins.yaml @@ -2,7 +2,7 @@ title: Jenkins author: '@timja' authorUrl: https://github.com/timja -category: CI +category: CI/CD description: Jenkins offers a simple way to set up a continuous integration and continuous delivery environment. documentation: https://github.com/backstage/backstage/tree/master/plugins/jenkins iconUrl: https://img.icons8.com/color/1600/jenkins.png diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml index 579243d11c..c32455c7d5 100644 --- a/microsite/data/plugins/jira.yaml +++ b/microsite/data/plugins/jira.yaml @@ -2,7 +2,7 @@ title: Jira author: roadie.io authorUrl: https://roadie.io -category: Project Management +category: Agile Planning description: View Jira summary for your projects in Backstage. documentation: https://roadie.io/backstage/plugins/jira iconUrl: https://roadie.io/images/logos/jira.png diff --git a/microsite/data/plugins/kafka.yaml b/microsite/data/plugins/kafka.yaml new file mode 100644 index 0000000000..47d354b44f --- /dev/null +++ b/microsite/data/plugins/kafka.yaml @@ -0,0 +1,11 @@ +--- +title: Kafka +author: '@nirga' +authorUrl: https://github.com/nirga +category: Monitoring +description: Observability for Apache Kafka clusters and async API of components. +documentation: https://github.com/backstage/backstage/tree/master/plugins/kafka +iconUrl: https://kafka.apache.org/images/apache-kafka.png +npmPackageName: '@backstage/plugin-kafka' +tags: + - monitoring diff --git a/microsite/data/plugins/kubernetes.yaml b/microsite/data/plugins/kubernetes.yaml deleted file mode 100644 index 3134edf858..0000000000 --- a/microsite/data/plugins/kubernetes.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Kubernetes -author: Spotify -authorUrl: https://github.com/spotify -category: Kubernetes -description: Surfaces components in a Kubernetes container orchestration environment into the Backstage catalog. -documentation: https://github.com/backstage/backstage/tree/master/plugins/kubernetes -iconUrl: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png -npmPackageName: '@backstage/plugin-kubernetes' -tags: - - kubernetes - - k8s diff --git a/microsite/data/plugins/lighthouse.yaml b/microsite/data/plugins/lighthouse.yaml index 88d444e0ef..be70dfd74a 100644 --- a/microsite/data/plugins/lighthouse.yaml +++ b/microsite/data/plugins/lighthouse.yaml @@ -2,7 +2,7 @@ title: Lighthouse author: Spotify authorUrl: https://github.com/spotify -category: Accessibility +category: Quality description: Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website. documentation: https://github.com/backstage/backstage/tree/master/plugins/lighthouse iconUrl: https://seeklogo.com/images/G/google-lighthouse-logo-1C7FA08580-seeklogo.com.png diff --git a/microsite/data/plugins/opsgenie.yaml b/microsite/data/plugins/opsgenie.yaml new file mode 100644 index 0000000000..43ba040f75 --- /dev/null +++ b/microsite/data/plugins/opsgenie.yaml @@ -0,0 +1,13 @@ +--- +title: Opsgenie +author: K-Phoen +authorUrl: https://github.com/K-Phoen +category: Monitoring +description: Opsgenie offers a simple way to associate alerts to components and vizualize incidents. +documentation: https://github.com/K-Phoen/backstage-plugin-opsgenie/ +iconUrl: https://avatars.githubusercontent.com/u/1818843?s=200&v=4 +npmPackageName: '@k-phoen/backstage-plugin-opsgenie' +tags: + - monitoring + - errors + - alerting diff --git a/microsite/data/plugins/pager-duty.yaml b/microsite/data/plugins/pager-duty.yaml new file mode 100644 index 0000000000..d0006c09ec --- /dev/null +++ b/microsite/data/plugins/pager-duty.yaml @@ -0,0 +1,13 @@ +--- +title: PagerDuty +author: Spotify +authorUrl: https://github.com/spotify +category: Monitoring +description: PagerDuty offers a simple way to identify any active incidents for an entity and the escalation policy. +documentation: https://github.com/backstage/backstage/tree/master/plugins/pagerduty +iconUrl: https://avatars2.githubusercontent.com/u/766800?s=200&v=4 +npmPackageName: '@backstage/plugin-pagerduty' +tags: + - monitoring + - errors + - alerting diff --git a/microsite/data/plugins/snyk-security.yaml b/microsite/data/plugins/snyk-security.yaml new file mode 100644 index 0000000000..35f00595d1 --- /dev/null +++ b/microsite/data/plugins/snyk-security.yaml @@ -0,0 +1,9 @@ +--- +title: Snyk +author: Snyk Tech Services +authorUrl: https://snyk.io +category: Security +description: View Snyk scanned vulnerabilities and license compliance of your components directly in Backstage. +documentation: https://github.com/snyk-tech-services/backstage-plugin-snyk/blob/main/README.md +iconUrl: https://storage.googleapis.com/snyk-technical-services.appspot.com/snyk-logo-vertical-black.png +npmPackageName: 'backstage-plugin-snyk' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index fe1b18c9d4..a6b8c6ebd7 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -2,7 +2,7 @@ title: Travis CI author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: CI/CD description: View Travis CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/travis-ci iconUrl: https://roadie.io/images/logos/travis.png diff --git a/microsite/package.json b/microsite/package.json index 6be0579475..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.66", - "js-yaml": "^3.14.0", + "docusaurus": "^2.0.0-alpha.70", + "js-yaml": "^4.0.0", "prettier": "^2.2.1" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index 63c15ab698..47ea1ee9ee 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -17,22 +17,18 @@ const Background = props => { - See us in action. + See us in action - To illustrate the potential of Backstage, we’re showing you{' '} - - how we use it here at Spotify + To explore the UI and basic features of Backstage firsthand, go + to: demo.backstage.io. + + + Watch the videos below to get an introduction to Backstage and to + see how we use different plugins to customize{' '} + + our internal version of Backstage at Spotify - . The videos below feature the custom plugins and templates that - we built for our internal version of Backstage. You can use - Backstage to build the developer portal you want — integrating the - tools that you use in your own infrastructure ecosystem. (Or get - started by building an open source plugin for Backstage that - everyone can use, like our{' '} - - Lighthouse Plugin - - .) + . @@ -104,9 +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 > @@ -139,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 e60d9c520e..e8d3ab1409 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -34,7 +34,17 @@ ] } ], + "CLI": ["cli/index", "cli/commands"], "Core Features": [ + { + "type": "subcategory", + "label": "Kubernetes", + "ids": [ + "features/kubernetes/overview", + "features/kubernetes/installation", + "features/kubernetes/configuration" + ] + }, { "type": "subcategory", "label": "Software Catalog", @@ -49,6 +59,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" ] }, @@ -65,6 +76,14 @@ "features/software-templates/extending/extending-preparer" ] }, + { + "type": "subcategory", + "label": "Backstage Search", + "ids": [ + "features/search/search-overview", + "features/search/architecture" + ] + }, { "type": "subcategory", "label": "TechDocs", @@ -74,6 +93,10 @@ "features/techdocs/concepts", "features/techdocs/architecture", "features/techdocs/creating-and-publishing", + "features/techdocs/configuration", + "features/techdocs/using-cloud-storage", + "features/techdocs/configuring-ci-cd", + "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" ] @@ -86,6 +109,7 @@ "plugins/plugin-development", "plugins/structure-of-a-plugin", "plugins/integrating-plugin-into-service-catalog", + "plugins/composability", { "type": "subcategory", "label": "Backends and APIs", @@ -163,10 +187,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 517cac2496..8d0c4ea57c 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -8,11 +8,8 @@ // See https://docusaurus.io/docs/site-config for all the possible // site configuration options. -// List of projects/orgs using your project for the users page. -const users = []; - const siteConfig = { - title: 'Backstage', // Title for your website. + title: 'Backstage Service Catalog and Developer Platform', // Title for your website. tagline: 'An open platform for building developer portals', url: 'https://backstage.io', // Your website URL cname: 'backstage.io', @@ -72,11 +69,6 @@ const siteConfig = { navGroupSubcategoryTitleColor: '#9e9e9e', }, - /* Colors for syntax highlighting */ - highlight: { - theme: 'dark', - }, - // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`, @@ -90,6 +82,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 c07285ed8c..c947c88e66 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -48,6 +48,19 @@ h6 { color: $textColor; } +summary { + color: $textColor; + cursor: pointer; +} + +h2:hover .hash-link { + opacity: 1; +} + +.hash-link { + fill: white; +} + /* body elements */ p, ul, @@ -1022,6 +1035,55 @@ code { } } +.Banner { + position: relative; + padding: 14px; + margin: 14px 20px; + border-radius: 4px; + background-color: $primaryColor; + font-family: Helvetica Neue, sans-serif; + color: #000; +} + +.Banner--hidden { + opacity: 0; + transition: opacity 200ms ease-in-out; +} + +.Banner a { + color: #000; + text-decoration: underline; +} + +.Banner__Container { + position: relative; + overflow: visible; + z-index: 100; + + max-width: 1430px; + height: 0; + margin: -14px auto 14px auto; +} + +.Banner__DismissButton { + position: absolute; + display: flex; + right: 8px; + top: 0; + bottom: 0; + margin: auto; + + border-radius: 50%; + padding: 6px; + width: 36px; + height: 36px; + cursor: pointer; +} + +.Banner__DismissButton:hover { + background: rgba(0, 0, 0, 0.2); +} + .logos-mobile-background { position: absolute; width: 200vw; 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 22efeb7a24..898fd69ad2 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -9,59 +9,54 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/code-frame@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" -"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" - integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== - dependencies: - browserslist "^4.12.0" - invariant "^2.2.4" - semver "^5.5.0" +"@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== -"@babel/core@^7.9.0": - version "7.11.6" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651" - integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg== +"@babel/core@^7.12.3": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" + integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.6" - "@babel/helper-module-transforms" "^7.11.0" - "@babel/helpers" "^7.10.4" - "@babel/parser" "^7.11.5" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.11.5" - "@babel/types" "^7.11.5" + "@babel/generator" "^7.12.10" + "@babel/helper-module-transforms" "^7.12.1" + "@babel/helpers" "^7.12.5" + "@babel/parser" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" json5 "^2.1.2" lodash "^4.17.19" - resolve "^1.3.2" semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.11.5", "@babel/generator@^7.11.6": - version "7.11.6" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620" - integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA== +"@babel/generator@^7.12.10", "@babel/generator@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" + integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== dependencies: - "@babel/types" "^7.11.5" + "@babel/types" "^7.12.11" jsesc "^2.5.1" source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" - integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== +"@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" + integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" @@ -71,54 +66,34 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.10.4", "@babel/helper-builder-react-jsx-experimental@^7.11.5": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.11.5.tgz#4ea43dd63857b0a35cd1f1b161dc29b43414e79f" - integrity sha512-Vc4aPJnRZKWfzeCBsqTBnzulVNjABVdahSPhtdMD3Vs80ykx4a87jTHtF/VR+alSrDmNvat7l13yrRHauGcHVw== +"@babel/helper-compilation-targets@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" - "@babel/types" "^7.11.5" - -"@babel/helper-builder-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" - integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/helper-compilation-targets@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2" - integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ== - dependencies: - "@babel/compat-data" "^7.10.4" - browserslist "^4.12.0" - invariant "^2.2.4" - levenary "^1.1.1" + "@babel/compat-data" "^7.12.5" + "@babel/helper-validator-option" "^7.12.1" + browserslist "^4.14.5" semver "^5.5.0" -"@babel/helper-create-class-features-plugin@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" - integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== +"@babel/helper-create-class-features-plugin@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" + integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== dependencies: "@babel/helper-function-name" "^7.10.4" - "@babel/helper-member-expression-to-functions" "^7.10.5" + "@babel/helper-member-expression-to-functions" "^7.12.1" "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" -"@babel/helper-create-regexp-features-plugin@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" - integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g== +"@babel/helper-create-regexp-features-plugin@^7.12.1": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" + integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - regexpu-core "^4.7.0" + regexpu-core "^4.7.1" "@babel/helper-define-map@^7.10.4": version "7.10.5" @@ -130,27 +105,27 @@ lodash "^4.17.19" "@babel/helper-explode-assignable-expression@^7.10.4": - version "7.11.4" - resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b" - integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ== + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.1.tgz#8006a466695c4ad86a2a5f2fb15b5f2c31ad5633" + integrity sha512-dmUwH8XmlrUpVqgtZ737tK88v07l840z9j3OEhCLwKTkjlvKpfqXVIZ0wpK3aeOxspwGrf/5AP5qLx4rO3w5rA== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" - integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== +"@babel/helper-function-name@^7.10.4", "@babel/helper-function-name@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" + integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-get-function-arity" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/types" "^7.12.11" -"@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== +"@babel/helper-get-function-arity@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" @@ -159,117 +134,115 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" - integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q== +"@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.7" -"@babel/helper-module-imports@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" - integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== +"@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" + integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.5" -"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" - integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== +"@babel/helper-module-transforms@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" + integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== dependencies: - "@babel/helper-module-imports" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-replace-supers" "^7.12.1" + "@babel/helper-simple-access" "^7.12.1" "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/helper-validator-identifier" "^7.10.4" "@babel/template" "^7.10.4" - "@babel/types" "^7.11.0" + "@babel/traverse" "^7.12.1" + "@babel/types" "^7.12.1" lodash "^4.17.19" -"@babel/helper-optimise-call-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" - integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== +"@babel/helper-optimise-call-expression@^7.10.4", "@babel/helper-optimise-call-expression@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-regex@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" - integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg== - dependencies: - lodash "^4.17.19" - -"@babel/helper-remap-async-to-generator@^7.10.4": - version "7.11.4" - resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz#4474ea9f7438f18575e30b0cac784045b402a12d" - integrity sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA== +"@babel/helper-remap-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" + integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-wrap-function" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-replace-supers@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf" - integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A== +"@babel/helper-replace-supers@^7.12.1": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" + integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== dependencies: - "@babel/helper-member-expression-to-functions" "^7.10.4" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.12.7" + "@babel/helper-optimise-call-expression" "^7.12.10" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.11" -"@babel/helper-simple-access@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" - integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== +"@babel/helper-simple-access@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== dependencies: - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-skip-transparent-expression-wrappers@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" - integrity sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q== +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.1" -"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" - integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" + integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.11" -"@babel/helper-validator-identifier@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" - integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== + +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" + integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== "@babel/helper-wrap-function@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" - integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug== + version "7.12.3" + resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.12.3.tgz#3332339fc4d1fbbf1c27d7958c27d34708e990d9" + integrity sha512-Cvb8IuJDln3rs6tzjW3Y8UeelAOdnpB8xtQ4sme2MSZ9wOxrbThporC0y/EtE16VAtoyEfLM404Xr1e0OOp+ow== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/template" "^7.10.4" "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helpers@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" - integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== +"@babel/helpers@^7.12.5": + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.5" "@babel/highlight@^7.0.0", "@babel/highlight@^7.10.4": version "7.10.4" @@ -280,116 +253,116 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.10.4", "@babel/parser@^7.11.5": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" - integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== +"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" + integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== -"@babel/plugin-proposal-async-generator-functions@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" - integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg== +"@babel/plugin-proposal-async-generator-functions@^7.12.1": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" + integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" -"@babel/plugin-proposal-class-properties@^7.10.4", "@babel/plugin-proposal-class-properties@^7.8.3": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807" - integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg== +"@babel/plugin-proposal-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" + integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== dependencies: - "@babel/helper-create-class-features-plugin" "^7.10.4" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-dynamic-import@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e" - integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ== +"@babel/plugin-proposal-dynamic-import@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" + integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-dynamic-import" "^7.8.0" -"@babel/plugin-proposal-export-namespace-from@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" - integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg== +"@babel/plugin-proposal-export-namespace-from@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" + integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db" - integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw== +"@babel/plugin-proposal-json-strings@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" + integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-json-strings" "^7.8.0" -"@babel/plugin-proposal-logical-assignment-operators@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8" - integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q== +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" + integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" - integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== +"@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" + integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" -"@babel/plugin-proposal-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06" - integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA== +"@babel/plugin-proposal-numeric-separator@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" + integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" - integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== +"@babel/plugin-proposal-object-rest-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" + integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-transform-parameters" "^7.10.4" + "@babel/plugin-transform-parameters" "^7.12.1" -"@babel/plugin-proposal-optional-catch-binding@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" - integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g== +"@babel/plugin-proposal-optional-catch-binding@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" + integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" - integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== +"@babel/plugin-proposal-optional-chaining@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-private-methods@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" - integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== +"@babel/plugin-proposal-private-methods@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" + integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== dependencies: - "@babel/helper-create-class-features-plugin" "^7.10.4" + "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" - integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== +"@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" + integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-async-generators@^7.8.0": @@ -399,10 +372,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" - integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== +"@babel/plugin-syntax-class-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -427,10 +400,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.10.4.tgz#39abaae3cbf710c4373d8429484e6ba21340166c" - integrity sha512-KCg9mio9jwiARCB7WAcQ7Y1q+qicILjoK8LP/VkPkEKaf5dkaZZK1EcTe91a3JJlZ3qy6L5s9X52boEYi8DM9g== +"@babel/plugin-syntax-jsx@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" + integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -476,346 +449,328 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d" - integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ== +"@babel/plugin-syntax-top-level-await@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" + integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-arrow-functions@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" - integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== +"@babel/plugin-transform-arrow-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" + integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37" - integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ== +"@babel/plugin-transform-async-to-generator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" + integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== dependencies: - "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-module-imports" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.10.4" + "@babel/helper-remap-async-to-generator" "^7.12.1" -"@babel/plugin-transform-block-scoped-functions@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8" - integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA== +"@babel/plugin-transform-block-scoped-functions@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" + integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.10.4": - version "7.11.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" - integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew== +"@babel/plugin-transform-block-scoping@^7.12.11": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" + integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-classes@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" - integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== +"@babel/plugin-transform-classes@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" + integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-define-map" "^7.10.4" "@babel/helper-function-name" "^7.10.4" "@babel/helper-optimise-call-expression" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb" - integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw== +"@babel/plugin-transform-computed-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" + integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-destructuring@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" - integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== +"@babel/plugin-transform-destructuring@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" + integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" - integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== +"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" + integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-duplicate-keys@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" - integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA== +"@babel/plugin-transform-duplicate-keys@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" + integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-exponentiation-operator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e" - integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw== +"@babel/plugin-transform-exponentiation-operator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" + integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== dependencies: "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-for-of@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" - integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== +"@babel/plugin-transform-for-of@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" + integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7" - integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg== +"@babel/plugin-transform-function-name@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" + integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-literals@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c" - integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ== +"@babel/plugin-transform-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" + integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-member-expression-literals@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7" - integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw== +"@babel/plugin-transform-member-expression-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" + integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-modules-amd@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1" - integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw== +"@babel/plugin-transform-modules-amd@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" + integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== dependencies: - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" - integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== +"@babel/plugin-transform-modules-commonjs@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" + integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== dependencies: - "@babel/helper-module-transforms" "^7.10.4" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-simple-access" "^7.12.1" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" - integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw== +"@babel/plugin-transform-modules-systemjs@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" + integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== dependencies: "@babel/helper-hoist-variables" "^7.10.4" - "@babel/helper-module-transforms" "^7.10.5" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-validator-identifier" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e" - integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA== +"@babel/plugin-transform-modules-umd@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" + integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== dependencies: - "@babel/helper-module-transforms" "^7.10.4" + "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6" - integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA== +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" + integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.12.1" -"@babel/plugin-transform-new-target@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888" - integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw== +"@babel/plugin-transform-new-target@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" + integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-object-super@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894" - integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ== +"@babel/plugin-transform-object-super@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" + integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-replace-supers" "^7.12.1" -"@babel/plugin-transform-parameters@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" - integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw== - dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-property-literals@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0" - integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g== +"@babel/plugin-transform-parameters@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" + integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-display-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.10.4.tgz#b5795f4e3e3140419c3611b7a2a3832b9aef328d" - integrity sha512-Zd4X54Mu9SBfPGnEcaGcOrVAYOtjT2on8QZkLKEq1S/tHexG39d9XXGZv19VfRrDjPJzFmPfTAqOQS1pfFOujw== +"@babel/plugin-transform-property-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" + integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-jsx-development@^7.10.4": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.11.5.tgz#e1439e6a57ee3d43e9f54ace363fb29cefe5d7b6" - integrity sha512-cImAmIlKJ84sDmpQzm4/0q/2xrXlDezQoixy3qoz1NJeZL/8PRon6xZtluvr4H4FzwlDGI5tCcFupMnXGtr+qw== - dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.11.5" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" - -"@babel/plugin-transform-react-jsx-self@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369" - integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg== +"@babel/plugin-transform-react-display-name@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" + integrity sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" -"@babel/plugin-transform-react-jsx-source@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.10.5.tgz#34f1779117520a779c054f2cdd9680435b9222b4" - integrity sha512-wTeqHVkN1lfPLubRiZH3o73f4rfon42HpgxUSs86Nc+8QIcm/B9s8NNVXu/gwGcOyd7yDib9ikxoDLxJP0UiDA== +"@babel/plugin-transform-react-jsx-development@^7.12.7": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.12.tgz#bccca33108fe99d95d7f9e82046bfe762e71f4e7" + integrity sha512-i1AxnKxHeMxUaWVXQOSIco4tvVvvCxMSfeBMnMM06mpaJt3g+MpxYQQrDfojUQldP1xxraPSJYSMEljoWM/dCg== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" + "@babel/plugin-transform-react-jsx" "^7.12.12" -"@babel/plugin-transform-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz#673c9f913948764a4421683b2bef2936968fddf2" - integrity sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A== +"@babel/plugin-transform-react-jsx@^7.12.10", "@babel/plugin-transform-react-jsx@^7.12.12": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.12.tgz#b0da51ffe5f34b9a900e9f1f5fb814f9e512d25e" + integrity sha512-JDWGuzGNWscYcq8oJVCtSE61a5+XAOos+V0HrxnDieUus4UMnBEosDnY1VJqU5iZ4pA04QY7l0+JvHL1hZEfsw== dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.10.4" + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.10.4" + "@babel/plugin-syntax-jsx" "^7.12.1" + "@babel/types" "^7.12.12" -"@babel/plugin-transform-react-pure-annotations@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.10.4.tgz#3eefbb73db94afbc075f097523e445354a1c6501" - integrity sha512-+njZkqcOuS8RaPakrnR9KvxjoG1ASJWpoIv/doyWngId88JoFlPlISenGXjrVacZUIALGUr6eodRs1vmPnF23A== +"@babel/plugin-transform-react-pure-annotations@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" + integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-regenerator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63" - integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw== +"@babel/plugin-transform-regenerator@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" + integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== dependencies: regenerator-transform "^0.14.2" -"@babel/plugin-transform-reserved-words@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd" - integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ== +"@babel/plugin-transform-reserved-words@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" + integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-shorthand-properties@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" - integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== +"@babel/plugin-transform-shorthand-properties@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" + integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-spread@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" - integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== +"@babel/plugin-transform-spread@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" + integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" -"@babel/plugin-transform-sticky-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" - integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - -"@babel/plugin-transform-template-literals@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" - integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-typeof-symbol@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc" - integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA== +"@babel/plugin-transform-sticky-regex@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" + integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-unicode-escapes@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" - integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg== +"@babel/plugin-transform-template-literals@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" + integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-unicode-regex@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8" - integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A== +"@babel/plugin-transform-typeof-symbol@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" + integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/polyfill@^7.8.7": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.11.5.tgz#df550b2ec53abbc2ed599367ec59e64c7a707bb5" - integrity sha512-FunXnE0Sgpd61pKSj2OSOs1D44rKTD3pGOfGilZ6LGrrIH0QEtJlTjqOqdF8Bs98JmjfGhni2BBkTfv9KcKJ9g== +"@babel/plugin-transform-unicode-escapes@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" + integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-unicode-regex@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" + integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.1" + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/polyfill@^7.12.1": + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" + integrity sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g== dependencies: core-js "^2.6.5" regenerator-runtime "^0.13.4" -"@babel/preset-env@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" - integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== +"@babel/preset-env@^7.12.1": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" + integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== dependencies: - "@babel/compat-data" "^7.11.0" - "@babel/helper-compilation-targets" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" + "@babel/compat-data" "^7.12.7" + "@babel/helper-compilation-targets" "^7.12.5" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-proposal-async-generator-functions" "^7.10.4" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-dynamic-import" "^7.10.4" - "@babel/plugin-proposal-export-namespace-from" "^7.10.4" - "@babel/plugin-proposal-json-strings" "^7.10.4" - "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" - "@babel/plugin-proposal-numeric-separator" "^7.10.4" - "@babel/plugin-proposal-object-rest-spread" "^7.11.0" - "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" - "@babel/plugin-proposal-optional-chaining" "^7.11.0" - "@babel/plugin-proposal-private-methods" "^7.10.4" - "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" + "@babel/helper-validator-option" "^7.12.11" + "@babel/plugin-proposal-async-generator-functions" "^7.12.1" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-dynamic-import" "^7.12.1" + "@babel/plugin-proposal-export-namespace-from" "^7.12.1" + "@babel/plugin-proposal-json-strings" "^7.12.1" + "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" + "@babel/plugin-proposal-numeric-separator" "^7.12.7" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.12.7" + "@babel/plugin-proposal-private-methods" "^7.12.1" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" - "@babel/plugin-syntax-class-properties" "^7.10.4" + "@babel/plugin-syntax-class-properties" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.0" @@ -825,45 +780,42 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" - "@babel/plugin-syntax-top-level-await" "^7.10.4" - "@babel/plugin-transform-arrow-functions" "^7.10.4" - "@babel/plugin-transform-async-to-generator" "^7.10.4" - "@babel/plugin-transform-block-scoped-functions" "^7.10.4" - "@babel/plugin-transform-block-scoping" "^7.10.4" - "@babel/plugin-transform-classes" "^7.10.4" - "@babel/plugin-transform-computed-properties" "^7.10.4" - "@babel/plugin-transform-destructuring" "^7.10.4" - "@babel/plugin-transform-dotall-regex" "^7.10.4" - "@babel/plugin-transform-duplicate-keys" "^7.10.4" - "@babel/plugin-transform-exponentiation-operator" "^7.10.4" - "@babel/plugin-transform-for-of" "^7.10.4" - "@babel/plugin-transform-function-name" "^7.10.4" - "@babel/plugin-transform-literals" "^7.10.4" - "@babel/plugin-transform-member-expression-literals" "^7.10.4" - "@babel/plugin-transform-modules-amd" "^7.10.4" - "@babel/plugin-transform-modules-commonjs" "^7.10.4" - "@babel/plugin-transform-modules-systemjs" "^7.10.4" - "@babel/plugin-transform-modules-umd" "^7.10.4" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" - "@babel/plugin-transform-new-target" "^7.10.4" - "@babel/plugin-transform-object-super" "^7.10.4" - "@babel/plugin-transform-parameters" "^7.10.4" - "@babel/plugin-transform-property-literals" "^7.10.4" - "@babel/plugin-transform-regenerator" "^7.10.4" - "@babel/plugin-transform-reserved-words" "^7.10.4" - "@babel/plugin-transform-shorthand-properties" "^7.10.4" - "@babel/plugin-transform-spread" "^7.11.0" - "@babel/plugin-transform-sticky-regex" "^7.10.4" - "@babel/plugin-transform-template-literals" "^7.10.4" - "@babel/plugin-transform-typeof-symbol" "^7.10.4" - "@babel/plugin-transform-unicode-escapes" "^7.10.4" - "@babel/plugin-transform-unicode-regex" "^7.10.4" + "@babel/plugin-syntax-top-level-await" "^7.12.1" + "@babel/plugin-transform-arrow-functions" "^7.12.1" + "@babel/plugin-transform-async-to-generator" "^7.12.1" + "@babel/plugin-transform-block-scoped-functions" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.11" + "@babel/plugin-transform-classes" "^7.12.1" + "@babel/plugin-transform-computed-properties" "^7.12.1" + "@babel/plugin-transform-destructuring" "^7.12.1" + "@babel/plugin-transform-dotall-regex" "^7.12.1" + "@babel/plugin-transform-duplicate-keys" "^7.12.1" + "@babel/plugin-transform-exponentiation-operator" "^7.12.1" + "@babel/plugin-transform-for-of" "^7.12.1" + "@babel/plugin-transform-function-name" "^7.12.1" + "@babel/plugin-transform-literals" "^7.12.1" + "@babel/plugin-transform-member-expression-literals" "^7.12.1" + "@babel/plugin-transform-modules-amd" "^7.12.1" + "@babel/plugin-transform-modules-commonjs" "^7.12.1" + "@babel/plugin-transform-modules-systemjs" "^7.12.1" + "@babel/plugin-transform-modules-umd" "^7.12.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" + "@babel/plugin-transform-new-target" "^7.12.1" + "@babel/plugin-transform-object-super" "^7.12.1" + "@babel/plugin-transform-parameters" "^7.12.1" + "@babel/plugin-transform-property-literals" "^7.12.1" + "@babel/plugin-transform-regenerator" "^7.12.1" + "@babel/plugin-transform-reserved-words" "^7.12.1" + "@babel/plugin-transform-shorthand-properties" "^7.12.1" + "@babel/plugin-transform-spread" "^7.12.1" + "@babel/plugin-transform-sticky-regex" "^7.12.7" + "@babel/plugin-transform-template-literals" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.10" + "@babel/plugin-transform-unicode-escapes" "^7.12.1" + "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.11.5" - browserslist "^4.12.0" - core-js-compat "^3.6.2" - invariant "^2.2.2" - levenary "^1.1.1" + "@babel/types" "^7.12.11" + core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": @@ -877,23 +829,21 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.9.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" - integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== +"@babel/preset-react@^7.12.5": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.10.tgz#4fed65f296cbb0f5fb09de6be8cddc85cc909be9" + integrity sha512-vtQNjaHRl4DUpp+t+g4wvTHsLQuye+n0H/wsXIZRn69oz/fvNC7gQ4IK73zGJBaxvHoxElDvnYCthMcT7uzFoQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-react-display-name" "^7.10.4" - "@babel/plugin-transform-react-jsx" "^7.10.4" - "@babel/plugin-transform-react-jsx-development" "^7.10.4" - "@babel/plugin-transform-react-jsx-self" "^7.10.4" - "@babel/plugin-transform-react-jsx-source" "^7.10.4" - "@babel/plugin-transform-react-pure-annotations" "^7.10.4" + "@babel/plugin-transform-react-display-name" "^7.12.1" + "@babel/plugin-transform-react-jsx" "^7.12.10" + "@babel/plugin-transform-react-jsx-development" "^7.12.7" + "@babel/plugin-transform-react-pure-annotations" "^7.12.1" -"@babel/register@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/register/-/register-7.11.5.tgz#79becf89e0ddd0fba8b92bc279bc0f5d2d7ce2ea" - integrity sha512-CAml0ioKX+kOAvBQDHa/+t1fgOt3qkTIz0TrRtRAT6XY0m5qYZXR85k6/sLCNPMGhYDlCFHCYuU0ybTJbvlC6w== +"@babel/register@^7.12.1": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/register/-/register-7.12.10.tgz#19b87143f17128af4dbe7af54c735663b3999f60" + integrity sha512-EvX/BvMMJRAA3jZgILWgbsrHwBQvllC5T8B29McyME8DvkdOxk4ujESfrMvME8IHSDvWXrmMXxPvA/lx2gqPLQ== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -902,42 +852,42 @@ source-map-support "^0.5.16" "@babel/runtime@^7.8.4": - version "7.11.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" - integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== +"@babel/template@^7.10.4", "@babel/template@^7.12.7": + version "7.12.7" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/parser" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/parser" "^7.12.7" + "@babel/types" "^7.12.7" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3" - integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ== +"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" + integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.5" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.11.5" - "@babel/types" "^7.11.5" + "@babel/code-frame" "^7.12.11" + "@babel/generator" "^7.12.11" + "@babel/helper-function-name" "^7.12.11" + "@babel/helper-split-export-declaration" "^7.12.11" + "@babel/parser" "^7.12.11" + "@babel/types" "^7.12.12" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.4.4", "@babel/types@^7.9.0": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" - integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.4.4": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" + integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== dependencies: - "@babel/helper-validator-identifier" "^7.10.4" + "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" @@ -965,21 +915,16 @@ integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== "@types/cheerio@^0.22.8": - version "0.22.21" - resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.21.tgz#5e37887de309ba11b2e19a6e14cad7874b31a8a3" - integrity sha512-aGI3DfswwqgKPiEOTaiHV2ZPC9KEhprpgEbJnv0fZl3SGX0cGgEva1126dGrMC6AJM6v/aihlUgJn9M5DbDZ/Q== + version "0.22.23" + resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.23.tgz#74bcfee9c5ee53f619711dca953a89fe5cfa4eb4" + integrity sha512-QfHLujVMlGqcS/ePSf3Oe5hK3H8wi/yN2JYuxSB1U10VvW1fO3K8C+mURQesFYS1Hn7lspOsTT75SKq/XtydQg== dependencies: "@types/node" "*" -"@types/color-name@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" - integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== - "@types/node@*": - version "14.6.4" - resolved "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz#a145cc0bb14ef9c4777361b7bbafa5cf8e3acb5a" - integrity sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ== + version "14.14.20" + resolved "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340" + integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A== "@types/q@^1.5.1": version "1.5.4" @@ -1000,9 +945,9 @@ address@1.1.2, address@^1.0.1: integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== ajv@^6.12.3: - version "6.12.4" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1054,11 +999,10 @@ ansi-styles@^3.2.1: color-convert "^1.9.0" ansi-styles@^4.1.0: - version "4.2.1" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" - integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - "@types/color-name" "^1.1.1" color-convert "^2.0.1" ansi-wrap@0.1.0: @@ -1075,9 +1019,9 @@ anymatch@^2.0.0: normalize-path "^2.1.1" arch@^2.1.0: - version "2.1.2" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" - integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== archive-type@^4.0.0: version "4.0.0" @@ -1093,6 +1037,11 @@ argparse@^1.0.10, argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -1174,15 +1123,20 @@ asynckit@^0.4.0: resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== autolinker@^3.11.0: - version "3.14.1" - resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.1.tgz#6ae4b812b6eaf42d4d68138b9e67757cbf2bc1e4" - integrity sha512-yvsRHIaY51EYDml6MGlbqyJGfl4n7zezGYf+R7gvM8c5LNpRGc4SISkvgAswSS8SWxk/OrGCylKV9mJyVstz7w== + version "3.14.2" + resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.2.tgz#71856274eb768fb7149039e24d3a2be2f5c55a63" + integrity sha512-VO66nXUCZFxTq7fVHAaiAkZNXRQ1l3IFi6D5P7DLoyIEAn2E8g7TWbyEgLlz1uW74LfWmu1A17IPWuPQyGuNVg== dependencies: tslib "^1.9.3" @@ -1212,9 +1166,9 @@ aws-sign2@~0.7.0: integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= aws4@^1.8.0: - version "1.10.1" - resolved "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428" - integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA== + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== babel-code-frame@^6.22.0: version "6.26.0" @@ -1242,10 +1196,10 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base64-js@^1.0.2: - version "1.3.1" - resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base@^0.11.1: version "0.11.2" @@ -1404,15 +1358,16 @@ browserslist@4.7.0: electron-to-chromium "^1.3.247" node-releases "^1.1.29" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5: - version "4.14.1" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3" - integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA== +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0: + version "4.16.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" + integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== dependencies: - caniuse-lite "^1.0.30001124" - electron-to-chromium "^1.3.562" - escalade "^3.0.2" - node-releases "^1.1.60" + caniuse-lite "^1.0.30001165" + colorette "^1.2.1" + electron-to-chromium "^1.3.621" + escalade "^3.1.1" + node-releases "^1.1.67" buffer-alloc-unsafe@^1.1.0: version "1.1.0" @@ -1443,12 +1398,12 @@ buffer-from@^1.0.0: integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== buffer@^5.2.1: - version "5.6.0" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" - integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + version "5.7.1" + resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" + base64-js "^1.3.1" + ieee754 "^1.1.13" bytes@1: version "1.0.0" @@ -1488,6 +1443,14 @@ cacheable-request@^2.1.1: normalize-url "2.0.1" responselike "1.0.2" +call-bind@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce" + integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.0" + call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -1535,10 +1498,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124: - version "1.0.30001124" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2" - integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: + version "1.0.30001173" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz#3c47bbe3cd6d7a9eda7f50ac016d158005569f56" + integrity sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw== caseless@~0.12.0: version "0.12.0" @@ -1718,21 +1681,21 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2: - version "1.5.3" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" - integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== +color-string@^1.5.4: + version "1.5.4" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" + integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" color@^3.0.0: - version "3.1.2" - resolved "https://registry.npmjs.org/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" - integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== + version "3.1.3" + resolved "https://registry.npmjs.org/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" + integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== dependencies: color-convert "^1.9.1" - color-string "^1.5.2" + color-string "^1.5.4" colorette@^1.2.1: version "1.2.1" @@ -1845,18 +1808,18 @@ copy-descriptor@^0.1.0: resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.6.2: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c" - integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng== +core-js-compat@^3.8.0: + version "3.8.2" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.2.tgz#3717f51f6c3d2ebba8cbf27619b57160029d1d4c" + integrity sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ== dependencies: - browserslist "^4.8.5" + browserslist "^4.16.0" semver "7.0.0" core-js@^2.6.5: - version "2.6.11" - resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + version "2.6.12" + resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -1948,12 +1911,12 @@ css-tree@1.0.0-alpha.37: mdn-data "2.0.4" source-map "^0.6.1" -css-tree@1.0.0-alpha.39: - version "1.0.0-alpha.39" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" - integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== +css-tree@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.2.tgz#9ae393b5dafd7dae8a622475caec78d3d8fbd7b5" + integrity sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ== dependencies: - mdn-data "2.0.6" + mdn-data "2.0.14" source-map "^0.6.1" css-what@2.1: @@ -1962,9 +1925,9 @@ css-what@2.1: integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== css-what@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" - integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== + version "3.4.2" + resolved "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" + integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== cssesc@^3.0.0: version "3.0.0" @@ -2040,11 +2003,11 @@ cssnano@^4.1.10: postcss "^7.0.0" csso@^4.0.2: - version "4.0.3" - resolved "https://registry.npmjs.org/csso/-/csso-4.0.3.tgz#0d9985dc852c7cc2b2cacfbbe1079014d1a8e903" - integrity sha512-NL3spysxUkcrOgnpsT4Xdl2aiEiBG6bXswAABQVHcMrfjjBisFOKwLDOmf4wf32aPdcJws1zds2B0Rg+jqMyHQ== + version "4.2.0" + resolved "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== dependencies: - css-tree "1.0.0-alpha.39" + css-tree "^1.1.2" currently-unhandled@^0.4.1: version "0.4.1" @@ -2067,24 +2030,17 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0: dependencies: ms "2.0.0" -debug@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" - integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg== +debug@4.3.1, debug@^4.1.0: + version "4.3.1" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: - ms "^2.1.1" + ms "2.1.2" debug@^3.1.0, debug@^3.1.1, debug@^3.2.5: - version "3.2.6" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@^4.1.0: - version "4.1.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" @@ -2163,7 +2119,7 @@ deep-is@^0.1.3: resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= -define-properties@^1.1.2, define-properties@^1.1.3: +define-properties@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== @@ -2233,20 +2189,20 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.66: - version "2.0.0-alpha.66" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.66.tgz#4dece48b838f773c4c973d9fe2546dd0bf2637cf" - integrity sha512-/HmRt3hEqpvZRdR2JRYKpwNUgalO14exxuwQu2vXW5eYzcKbQ922+3SxG7mF+LE8f7KOmgY+A3eS3SEyq+S3XA== +docusaurus@^2.0.0-alpha.70: + version "2.0.0-alpha.378053ac5" + resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" + integrity sha512-+NM1NrJKYcmHYiMQ/4b5ew9QDbLW3QI/ii2lCs/gpEZhtU0FVnl1RLC3FUO41xgOhth3A8M5G5ChzlPuNzxjug== dependencies: - "@babel/core" "^7.9.0" - "@babel/plugin-proposal-class-properties" "^7.8.3" - "@babel/plugin-proposal-object-rest-spread" "^7.9.0" - "@babel/polyfill" "^7.8.7" - "@babel/preset-env" "^7.9.0" - "@babel/preset-react" "^7.9.4" - "@babel/register" "^7.9.0" - "@babel/traverse" "^7.9.0" - "@babel/types" "^7.9.0" + "@babel/core" "^7.12.3" + "@babel/plugin-proposal-class-properties" "^7.12.1" + "@babel/plugin-proposal-object-rest-spread" "^7.12.1" + "@babel/polyfill" "^7.12.1" + "@babel/preset-env" "^7.12.1" + "@babel/preset-react" "^7.12.5" + "@babel/register" "^7.12.1" + "@babel/traverse" "^7.12.5" + "@babel/types" "^7.12.6" autoprefixer "^9.7.5" babylon "^6.18.0" chalk "^3.0.0" @@ -2256,10 +2212,10 @@ docusaurus@^2.0.0-alpha.66: cssnano "^4.1.10" escape-string-regexp "^2.0.0" express "^4.17.1" - feed "^4.0.0" - fs-extra "^8.1.0" + feed "^4.2.1" + fs-extra "^9.0.1" gaze "^1.1.3" - github-slugger "^1.2.1" + github-slugger "^1.3.0" glob "^7.1.6" highlight.js "^9.16.2" imagemin "^6.0.0" @@ -2267,12 +2223,12 @@ docusaurus@^2.0.0-alpha.66: imagemin-jpegtran "^6.0.0" imagemin-optipng "^6.0.0" imagemin-svgo "^7.0.0" - lodash "^4.17.15" + lodash "^4.17.20" markdown-toc "^1.2.0" mkdirp "^0.5.1" - portfinder "^1.0.25" + portfinder "^1.0.28" postcss "^7.0.23" - prismjs "^1.17.1" + prismjs "^1.22.0" react "^16.8.4" react-dev-utils "^9.1.0" react-dom "^16.8.4" @@ -2307,9 +2263,9 @@ domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== domelementtype@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" - integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + version "2.1.0" + resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" + integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== domhandler@^2.3.0: version "2.4.2" @@ -2335,9 +2291,9 @@ domutils@^1.5.1, domutils@^1.7.0: domelementtype "1" dot-prop@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" - integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== + version "5.3.0" + resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== dependencies: is-obj "^2.0.0" @@ -2399,10 +2355,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.562: - version "1.3.562" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f" - integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg== +electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.621: + version "1.3.634" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz#82ea400f520f739c4f6ff00c1f7524827a917d25" + integrity sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw== "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" @@ -2432,9 +2388,9 @@ entities@^1.1.1, entities@~1.1.1: integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== entities@^2.0.0: - version "2.0.3" - resolved "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" - integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== + version "2.1.0" + resolved "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" @@ -2450,20 +2406,38 @@ error@^7.0.0: dependencies: string-template "~0.2.1" -es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== +es-abstract@^1.17.2: + version "1.17.7" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" - object-inspect "^1.7.0" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" object-keys "^1.1.1" - object.assign "^4.1.0" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" @@ -2476,10 +2450,10 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -escalade@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" - integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-html@~1.0.3: version "1.0.3" @@ -2729,7 +2703,7 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -feed@^4.0.0: +feed@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" integrity sha512-l28KKcK1J/u3iq5dRDmmoB2p7dtBfACC2NqJh4dI2kFptxH0asfjmOfcxqh5Sv8suAlVa73gZJ4REY5RrafVvg== @@ -2940,14 +2914,15 @@ fs-constants@^1.0.0: resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@^8.1.0: - version "8.1.0" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== +fs-extra@^9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== dependencies: + at-least-node "^1.0.0" graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" + jsonfile "^6.0.1" + universalify "^1.0.0" fs.realpath@^1.0.0: version "1.0.0" @@ -2975,9 +2950,18 @@ gaze@^1.1.3: globule "^1.0.0" gensync@^1.0.0-beta.1: - version "1.0.0-beta.1" - resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" - integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== + version "1.0.0-beta.2" + resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-intrinsic@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" + integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" get-proxy@^2.0.0: version "2.1.0" @@ -3033,7 +3017,7 @@ gifsicle@^4.0.0: execa "^1.0.0" logalot "^2.0.0" -github-slugger@^1.2.1: +github-slugger@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== @@ -3226,7 +3210,7 @@ has-symbol-support-x@^1.4.1: resolved "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw== -has-symbols@^1.0.0, has-symbols@^1.0.1: +has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== @@ -3282,9 +3266,9 @@ hex-color-regex@^1.1.0: integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== highlight.js@^9.16.2: - version "9.18.3" - resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.3.tgz#a1a0a2028d5e3149e2380f8a865ee8516703d634" - integrity sha512-zBZAmhSupHIl5sITeMqIJnYCDfAEc3Gdkqj65wC1lpI468MMQeeQkhcIAvk+RylAkxrCcI9xy9piHiXeQ1BdzQ== + version "9.18.5" + resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" + integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: version "2.8.8" @@ -3346,9 +3330,9 @@ http-errors@~1.7.2: toidentifier "1.0.0" http-parser-js@>=0.5.1: - version "0.5.2" - resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" - integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + version "0.5.3" + resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" + integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== http-signature@~1.2.0: version "1.2.0" @@ -3366,10 +3350,10 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.4: - version "1.1.13" - resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== ignore@^3.3.5: version "3.3.10" @@ -3472,9 +3456,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5: - version "1.3.5" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@6.5.0: version "6.5.0" @@ -3508,17 +3492,10 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.2, invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^4.1.0: + version "4.2.0" + resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz#a03f5eb661d9a154e3973a03de8b23dd0ad6892e" + integrity sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A== ipaddr.js@1.9.1: version "1.9.1" @@ -3566,10 +3543,10 @@ is-buffer@^1.1.5: resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.4, is-callable@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" - integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== +is-callable@^1.1.4, is-callable@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== is-color-stop@^1.0.0: version "1.1.0" @@ -3583,6 +3560,13 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" +is-core-module@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -3683,6 +3667,11 @@ is-natural-number@^4.0.1: resolved "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= +is-negative-zero@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + is-number@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -3708,9 +3697,9 @@ is-obj@^2.0.0: integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== is-object@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" - integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA= + version "1.0.2" + resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf" + integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA== is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: version "1.1.0" @@ -3729,7 +3718,7 @@ is-png@^1.0.0: resolved "https://registry.npmjs.org/is-png/-/is-png-1.1.0.tgz#d574b12bf275c0350455570b0e5b57ab062077ce" integrity sha1-1XSxK/J1wDUEVVcLDltXqwYgd84= -is-regex@^1.1.0: +is-regex@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -3782,7 +3771,7 @@ is-typedarray@~1.0.0: resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -is-url@^1.2.2: +is-url@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== @@ -3802,14 +3791,14 @@ is-wsl@^1.1.0: resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is2@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is2/-/is2-2.0.1.tgz#8ac355644840921ce435d94f05d3a94634d3481a" - integrity sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA== +is2@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/is2/-/is2-2.0.6.tgz#094f887248b49ba7ce278f8c39f85a70927bb5de" + integrity sha512-+Z62OHOjA6k2sUDOKXoZI3EXv7Fb1K52jpTBLbkfx62bcUeSsrTBLhEquCRDKTx0XE5XbHcG/S2vrtE3lnEDsQ== dependencies: deep-is "^0.1.3" - ip-regex "^2.1.0" - is-url "^1.2.2" + ip-regex "^4.1.0" + is-url "^1.2.4" isarray@1.0.0, isarray@~1.0.0: version "1.0.0" @@ -3865,14 +3854,21 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.1: - version "3.14.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== +js-yaml@^3.13.1, js-yaml@^3.8.1: + version "3.14.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -3932,10 +3928,12 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" optionalDependencies: graceful-fs "^4.1.6" @@ -3987,18 +3985,6 @@ lazy-cache@^2.0.2: dependencies: set-getter "^0.1.0" -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -levenary@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" - integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== - dependencies: - leven "^3.1.0" - list-item@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz#0c65d00e287cb663ccb3cb3849a77e89ec268a56" @@ -4155,7 +4141,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@~4.17.10: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20, lodash@~4.17.10: version "4.17.20" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -4173,7 +4159,7 @@ longest@^1.0.0: resolved "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4276,16 +4262,16 @@ math-random@^1.0.1: resolved "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== -mdn-data@2.0.6: - version "2.0.6" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" - integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -4346,17 +4332,17 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -mime-db@1.44.0, mime-db@^1.28.0: - version "1.44.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== +mime-db@1.45.0, mime-db@^1.28.0: + version "1.45.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" + integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.27" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + version "2.1.28" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== dependencies: - mime-db "1.44.0" + mime-db "1.45.0" mime@1.6.0: version "1.6.0" @@ -4410,20 +4396,25 @@ ms@2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= nan@^2.12.1: - version "2.14.1" - resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" - integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + version "2.14.2" + resolved "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nanomatch@^1.2.9: version "1.2.13" @@ -4457,10 +4448,10 @@ node-modules-regexp@^1.0.0: resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= -node-releases@^1.1.29, node-releases@^1.1.60: - version "1.1.60" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" - integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== +node-releases@^1.1.29, node-releases@^1.1.67: + version "1.1.69" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz#3149dbde53b781610cd8b486d62d86e26c3725f6" + integrity sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA== normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.5.0" @@ -4549,12 +4540,12 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" - integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== +object-inspect@^1.8.0: + version "1.9.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== -object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: +object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== @@ -4566,23 +4557,24 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" -object.assign@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== +object.assign@^4.1.0, object.assign@^4.1.1: + version "4.1.2" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" object.getownpropertydescriptors@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" - integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + version "2.1.1" + resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz#0dfda8d108074d9c563e80490c883b6661091544" + integrity sha512-6DtXgZ/lIZ9hqx4GtZETobXLR/ZLaa0aqV0kzbn80Rf8Z2e/XFnhA0I7p07N2wH8bBBltr2xQPi6sbKWAY2Eng== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" + es-abstract "^1.18.0-next.1" object.pick@^1.2.0, object.pick@^1.3.0: version "1.3.0" @@ -4592,13 +4584,13 @@ object.pick@^1.2.0, object.pick@^1.3.0: isobject "^3.0.1" object.values@^1.1.0: - version "1.1.1" - resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" - integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== + version "1.1.2" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731" + integrity sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.18.0-next.1" has "^1.0.3" on-finished@~2.3.0: @@ -4896,7 +4888,7 @@ pkg-up@2.0.0: dependencies: find-up "^2.1.0" -portfinder@^1.0.25: +portfinder@^1.0.28: version "1.0.28" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== @@ -4911,9 +4903,9 @@ posix-character-classes@^0.1.0: integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= postcss-calc@^7.0.1: - version "7.0.4" - resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.4.tgz#5e177ddb417341e6d4a193c5d9fd8ada79094f8b" - integrity sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw== + version "7.0.5" + resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.5.tgz#f8a6e99f12e619c2ebc23cf6c486fdc15860933e" + integrity sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg== dependencies: postcss "^7.0.27" postcss-selector-parser "^6.0.2" @@ -5148,13 +5140,14 @@ postcss-selector-parser@^3.0.0: uniq "^1.0.1" postcss-selector-parser@^6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" - integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + version "6.0.4" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== dependencies: cssesc "^3.0.0" indexes-of "^1.0.1" uniq "^1.0.1" + util-deprecate "^1.0.2" postcss-svgo@^4.0.2: version "4.0.2" @@ -5186,9 +5179,9 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.23, postcss@^7.0.27, postcss@^7.0.32: - version "7.0.32" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" - integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== + version "7.0.35" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -5209,10 +5202,10 @@ prettier@^2.2.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== -prismjs@^1.17.1: - version "1.21.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" - integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw== +prismjs@^1.22.0: + version "1.23.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== optionalDependencies: clipboard "^2.0.0" @@ -5364,9 +5357,9 @@ react-dev-utils@^9.1.0: text-table "0.2.0" react-dom@^16.8.4: - version "16.13.1" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== + version "16.14.0" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -5374,9 +5367,9 @@ react-dom@^16.8.4: scheduler "^0.19.1" react-error-overlay@^6.0.3: - version "6.0.7" - resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" - integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== + version "6.0.8" + resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" + integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw== react-is@^16.8.1: version "16.13.1" @@ -5384,9 +5377,9 @@ react-is@^16.8.1: integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react@^16.8.4: - version "16.13.1" - resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" - integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== + version "16.14.0" + resolved "https://registry.npmjs.org/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -5470,9 +5463,9 @@ regenerate-unicode-properties@^8.2.0: regenerate "^1.4.0" regenerate@^1.4.0: - version "1.4.1" - resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f" - integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A== + version "1.4.2" + resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" + integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== regenerator-runtime@^0.13.4: version "0.13.7" @@ -5494,10 +5487,10 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexpu-core@^4.7.0: - version "4.7.0" - resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938" - integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ== +regexpu-core@^4.7.1: + version "4.7.1" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== dependencies: regenerate "^1.4.0" regenerate-unicode-properties "^8.2.0" @@ -5602,11 +5595,12 @@ resolve-url@^0.2.1: resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.10.0, resolve@^1.3.2: - version "1.17.0" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" - integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== +resolve@^1.1.6, resolve@^1.10.0: + version "1.19.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== dependencies: + is-core-module "^2.1.0" path-parse "^1.0.6" responselike@1.0.2: @@ -5652,9 +5646,9 @@ run-async@^2.2.0: integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== rxjs@^6.4.0: - version "6.6.2" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" - integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== + version "6.6.3" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== dependencies: tslib "^1.9.0" @@ -5955,9 +5949,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.5" - resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" - integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== + version "3.0.7" + resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" + integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" @@ -6032,20 +6026,20 @@ string-width@^2.1.0: strip-ansi "^4.0.0" string.prototype.trimend@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" - integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" + integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.5" string.prototype.trimstart@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" - integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" + integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.5" string_decoder@0.10: version "0.10.31" @@ -6198,12 +6192,12 @@ tar-stream@^1.5.2: xtend "^4.0.0" tcp-port-used@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70" - integrity sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q== + version "1.0.2" + resolved "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz#9652b7436eb1f4cfae111c79b558a25769f6faea" + integrity sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA== dependencies: - debug "4.1.0" - is2 "2.0.1" + debug "4.3.1" + is2 "^2.0.6" temp-dir@^1.0.0: version "1.0.0" @@ -6358,9 +6352,9 @@ truncate-html@^1.0.3: cheerio "0.22.0" tslib@^1.9.0, tslib@^1.9.3: - version "1.13.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + version "1.14.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tunnel-agent@^0.6.0: version "0.6.0" @@ -6438,10 +6432,15 @@ uniqs@^2.0.0: resolved "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" @@ -6510,7 +6509,7 @@ use@^3.1.0: resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= diff --git a/mkdocs.yml b/mkdocs.yml index 1086259710..a2cbf11b4c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,20 +48,29 @@ nav: - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Backstage Search: + - Overview: 'features/search/README.md' + - Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' + - Configuration: 'features/techdocs/configuration.md' + - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' + - Kubernetes: + - Overview: 'features/kubernetes/index.md' - Plugins: - Overview: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - Creating a new plugin: 'plugins/create-a-plugin.md' - Developing a plugin: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - Backstage backend plugin: 'plugins/backend-plugin.md' @@ -69,7 +78,7 @@ nav: - Testing: - Overview: 'plugins/testing.md' - Publishing: - - Open source and NPM: 'plugins/publishing.md' + - Open source and npm: 'plugins/publishing.md' - Private/internal (non-open source): 'plugins/publish-private.md' - Configuration: - Overview: 'conf/index.md' @@ -109,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 3be94c19cf..758a8ba884 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,10 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docgen": "lerna run docgen", - "docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage", "docker-build": "yarn tsc && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", - "release": "changeset version && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}'", + "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install --frozen-lockfile", "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", @@ -43,8 +42,9 @@ "version": "1.0.0", "devDependencies": { "@changesets/cli": "^2.11.0", + "@octokit/openapi-types": "^2.2.0", "@spotify/eslint-config-oss": "^1.0.1", - "@spotify/prettier-config": "^8.0.0", + "@spotify/prettier-config": "^9.0.0", "command-exists": "^1.2.9", "concurrently": "^5.2.0", "fs-extra": "^9.0.0", @@ -75,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 a6c8da7fd9..d6157f560f 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,241 @@ # 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 + +- Updated dependencies [19554f6d6] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [7d72f9b09] + - @backstage/cli@0.4.3 + - @backstage/plugin-github-actions@0.2.6 + - @backstage/plugin-scaffolder@0.3.5 + - @backstage/core@0.4.2 + - @backstage/test-utils@0.1.6 + - @backstage/plugin-graphiql@0.2.3 + - @backstage/plugin-catalog@0.2.8 + - @backstage/plugin-pagerduty@0.2.4 + - @backstage/plugin-techdocs@0.5.1 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [c0fac6163] +- Updated dependencies [48c305e69] +- Updated dependencies [c911061b7] +- Updated dependencies [ab805860a] +- Updated dependencies [dae4f3983] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [f3e75508d] +- Updated dependencies [3b50f833d] +- Updated dependencies [c36a01b4c] +- Updated dependencies [c5297baeb] +- Updated dependencies [ac3560b42] +- Updated dependencies [962d1ad66] + - @backstage/plugin-org@0.3.2 + - @backstage/plugin-cost-insights@0.5.2 + - @backstage/catalog-model@0.6.0 + - @backstage/plugin-techdocs@0.5.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog-import@0.3.2 + - @backstage/plugin-pagerduty@0.2.3 + - @backstage/cli@0.4.2 + - @backstage/plugin-sentry@0.3.1 + - @backstage/plugin-api-docs@0.4.1 + - @backstage/plugin-catalog@0.2.7 + - @backstage/plugin-circleci@0.2.5 + - @backstage/plugin-cloudbuild@0.2.5 + - @backstage/plugin-github-actions@0.2.5 + - @backstage/plugin-jenkins@0.3.4 + - @backstage/plugin-kubernetes@0.3.3 + - @backstage/plugin-lighthouse@0.2.6 + - @backstage/plugin-register-component@0.2.5 + - @backstage/plugin-rollbar@0.2.7 + - @backstage/plugin-scaffolder@0.3.4 + - @backstage/plugin-search@0.2.4 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [b2a07d2dc] +- Updated dependencies [075d3dc5a] +- Updated dependencies [e3071a0d4] +- Updated dependencies [d6e8099ed] +- Updated dependencies [88ef11b45] +- Updated dependencies [06dbe707b] +- Updated dependencies [2527628e1] +- Updated dependencies [011708102] +- Updated dependencies [04efbbdd2] +- Updated dependencies [90458fed6] +- Updated dependencies [6011b7d3e] +- Updated dependencies [79418ddb6] +- Updated dependencies [d2938af4c] +- Updated dependencies [61897fb2c] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [6a0d7a9fb] +- Updated dependencies [0f8877168] +- Updated dependencies [1c69d4716] +- Updated dependencies [246799c7f] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [87a33d2fe] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] +- Updated dependencies [2b71db211] + - @backstage/plugin-jenkins@0.3.3 + - @backstage/plugin-sentry@0.3.0 + - @backstage/plugin-cost-insights@0.5.0 + - @backstage/cli@0.4.1 + - @backstage/core@0.4.0 + - @backstage/plugin-circleci@0.2.4 + - @backstage/plugin-catalog@0.2.6 + - @backstage/plugin-pagerduty@0.2.2 + - @backstage/plugin-catalog-import@0.3.1 + - @backstage/test-utils@0.1.5 + - @backstage/plugin-search@0.2.3 + - @backstage/plugin-api-docs@0.4.0 + - @backstage/catalog-model@0.5.0 + - @backstage/plugin-techdocs@0.4.0 + - @backstage/theme@0.2.2 + - @backstage/plugin-org@0.3.1 + - @backstage/plugin-cloudbuild@0.2.4 + - @backstage/plugin-explore@0.2.2 + - @backstage/plugin-gcp-projects@0.2.2 + - @backstage/plugin-github-actions@0.2.4 + - @backstage/plugin-gitops-profiles@0.2.2 + - @backstage/plugin-graphiql@0.2.2 + - @backstage/plugin-kubernetes@0.3.2 + - @backstage/plugin-lighthouse@0.2.5 + - @backstage/plugin-newrelic@0.2.2 + - @backstage/plugin-register-component@0.2.4 + - @backstage/plugin-rollbar@0.2.6 + - @backstage/plugin-scaffolder@0.3.3 + - @backstage/plugin-tech-radar@0.3.1 + - @backstage/plugin-user-settings@0.2.3 + - @backstage/plugin-welcome@0.2.3 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [7eb8bfe4a] +- Updated dependencies [fe7257ff0] +- Updated dependencies [a2cfa311a] +- Updated dependencies [69f38457f] +- Updated dependencies [bec334b33] +- Updated dependencies [303c5ea17] +- Updated dependencies [b4488ddb0] +- Updated dependencies [4a655c89d] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [8a16e8af8] +- Updated dependencies [bcc211a08] +- Updated dependencies [00670a96e] +- Updated dependencies [da2ad65cb] +- Updated dependencies [ebf37bbae] + - @backstage/plugin-api-docs@0.3.1 + - @backstage/plugin-cost-insights@0.4.2 + - @backstage/plugin-sentry@0.2.4 + - @backstage/plugin-welcome@0.2.2 + - @backstage/cli@0.4.0 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog-import@0.3.0 + - @backstage/plugin-scaffolder@0.3.2 + - @backstage/plugin-kubernetes@0.3.1 + - @backstage/plugin-techdocs@0.3.1 + - @backstage/plugin-catalog@0.2.5 + - @backstage/test-utils@0.1.4 + - @backstage/plugin-circleci@0.2.3 + - @backstage/plugin-cloudbuild@0.2.3 + - @backstage/plugin-github-actions@0.2.3 + - @backstage/plugin-jenkins@0.3.2 + - @backstage/plugin-lighthouse@0.2.4 + - @backstage/plugin-register-component@0.2.3 + - @backstage/plugin-rollbar@0.2.5 + - @backstage/plugin-search@0.2.2 + ## 0.2.4 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index c176f74cf6..7734dfc244 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,45 +1,47 @@ { "name": "example-app", - "version": "0.2.4", + "version": "0.2.12", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.3.1", - "@backstage/cli": "^0.3.2", - "@backstage/core": "^0.3.2", - "@backstage/plugin-api-docs": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.4", - "@backstage/plugin-catalog-import": "^0.2.0", - "@backstage/plugin-circleci": "^0.2.2", - "@backstage/plugin-cloudbuild": "^0.2.2", - "@backstage/plugin-cost-insights": "^0.4.1", - "@backstage/plugin-explore": "^0.2.1", - "@backstage/plugin-gcp-projects": "^0.2.1", - "@backstage/plugin-github-actions": "^0.2.2", - "@backstage/plugin-gitops-profiles": "^0.2.1", - "@backstage/plugin-graphiql": "^0.2.1", - "@backstage/plugin-jenkins": "^0.3.1", - "@backstage/plugin-kubernetes": "^0.3.0", - "@backstage/plugin-lighthouse": "^0.2.3", - "@backstage/plugin-newrelic": "^0.2.1", - "@backstage/plugin-register-component": "^0.2.2", - "@backstage/plugin-rollbar": "^0.2.4", - "@backstage/plugin-scaffolder": "^0.3.1", - "@backstage/plugin-sentry": "^0.2.3", - "@backstage/plugin-search": "^0.2.1", - "@backstage/plugin-tech-radar": "^0.3.0", - "@backstage/plugin-techdocs": "^0.3.0", - "@backstage/plugin-user-settings": "^0.2.2", - "@backstage/plugin-welcome": "^0.2.1", - "@backstage/test-utils": "^0.1.3", - "@backstage/theme": "^0.2.1", + "@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", "@roadiehq/backstage-plugin-travis-ci": "^0.2.8", - "@roadiehq/backstage-plugin-buildkite": "^0.1.3", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", @@ -51,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", @@ -88,5 +91,8 @@ "last 1 safari version" ] }, + "files": [ + "dist" + ], "license": "Apache-2.0" } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 28aebeeb79..1b76773979 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,8 +20,9 @@ import { OAuthRequestDialog, SignInPage, createRouteRef, + FlatRoutes, } from '@backstage/core'; -import React, { FC } from 'react'; +import React from 'react'; import Root from './components/Root'; import * as plugins from './plugins'; import { apis } from './apis'; @@ -35,7 +36,7 @@ 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,41 +66,39 @@ const catalogRouteRef = createRouteRef({ title: 'Service Catalog', }); -const AppRoutes = () => ( - +const routes = ( + } /> } /> - } /> + } /> } /> } /> - } /> + } /> } /> } /> {...deprecatedAppRoutes} - + ); -const App: FC<{}> = () => ( +const App = () => ( - - - + {routes} ); diff --git a/packages/app/src/components/Root/LogoFull.tsx b/packages/app/src/components/Root/LogoFull.tsx index d2b1bf1080..2fb767465b 100644 --- a/packages/app/src/components/Root/LogoFull.tsx +++ b/packages/app/src/components/Root/LogoFull.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -26,7 +26,7 @@ const useStyles = makeStyles({ fill: '#7df3e1', }, }); -const LogoFull: FC<{}> = () => { +const LogoFull = () => { const classes = useStyles(); return ( diff --git a/packages/app/src/components/Root/LogoIcon.tsx b/packages/app/src/components/Root/LogoIcon.tsx index d70be3dd32..507e47ddb9 100644 --- a/packages/app/src/components/Root/LogoIcon.tsx +++ b/packages/app/src/components/Root/LogoIcon.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -27,7 +27,7 @@ const useStyles = makeStyles({ }, }); -const LogoIcon: FC<{}> = () => { +const LogoIcon = () => { const classes = useStyles(); return ( diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 47e7d32d1a..52dd397418 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ -import React, { FC, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { useContext, PropsWithChildren } from 'react'; import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; import ExtensionIcon from '@material-ui/icons/Extension'; @@ -55,7 +54,7 @@ const useSidebarLogoStyles = makeStyles({ }, }); -const SidebarLogo: FC<{}> = () => { +const SidebarLogo = () => { const classes = useSidebarLogoStyles(); const { isOpen } = useContext(SidebarContext); @@ -73,7 +72,7 @@ const SidebarLogo: FC<{}> = () => { ); }; -const Root: FC<{}> = ({ children }) => ( +const Root = ({ children }: PropsWithChildren<{}>) => ( @@ -102,8 +101,4 @@ const Root: FC<{}> = ({ children }) => ( ); -Root.propTypes = { - children: PropTypes.node, -}; - export default Root; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 068019c622..a1ab181211 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,11 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiEntity, Entity } from '@backstage/catalog-model'; +import { + ApiEntity, + Entity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; import { EmptyState } from '@backstage/core'; import { ApiDefinitionCard, - Router as ApiDocsRouter, + ConsumedApisCard, + ConsumingComponentsCard, + ProvidedApisCard, + ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { AboutCard, @@ -48,7 +56,14 @@ import { isPluginApplicableToEntity as isLighthouseAvailable, LastLighthouseAuditCard, } from '@backstage/plugin-lighthouse'; +import { + OwnershipCard, + MembersListCard, + GroupProfileCard, + 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 { @@ -67,6 +82,10 @@ import { PullRequestsStatsCard, Router as PullRequestsRouter, } from '@roadiehq/backstage-plugin-github-pull-requests'; +import { + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from '@backstage/plugin-pagerduty'; import { isPluginApplicableToEntity as isTravisCIAvailable, RecentTravisCIBuildsWidget, @@ -142,6 +161,11 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( + {isPagerDutyAvailable(entity) && ( + + + + )} {isGitHubAvailable(entity) && ( <> @@ -167,6 +191,17 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( ); +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + +); + const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ( } + element={} /> ( title="Code Insights" element={} /> + } + /> ); @@ -288,6 +328,14 @@ const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + ); @@ -314,6 +362,51 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => ( ); +const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( + + + + + + + + +); + +const UserEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( + + + + + + + + + + + +); + +const GroupEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + export const EntityPage = () => { const { entity } = useEntity(); @@ -322,6 +415,10 @@ export const EntityPage = () => { return ; case 'api': return ; + case 'group': + return ; + case 'user': + return ; default: return ; } diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index a38159a258..71fd40dd7e 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -// eslint-disable-next-line monorepo/no-internal-import import '@backstage/cli/asset-types'; import React from 'react'; import ReactDOM from 'react-dom'; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a02365d17d..f07be07ec1 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -39,5 +39,8 @@ export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights'; export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; export { plugin as UserSettings } from '@backstage/plugin-user-settings'; +export { plugin as PagerDuty } from '@backstage/plugin-pagerduty'; export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite'; export { plugin as Search } from '@backstage/plugin-search'; +export { plugin as Org } from '@backstage/plugin-org'; +export { plugin as Kafka } from '@backstage/plugin-kafka'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index aec6c37a49..84265c9897 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,213 @@ # @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 + +- 1d1c2860f: Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl +- 4eafdec4a: Introduce readTree method for GitLab URL Reader +- Updated dependencies [1d1c2860f] +- Updated dependencies [4eafdec4a] +- Updated dependencies [178e09323] + - @backstage/integration@0.1.4 + +## 0.4.0 + +### Minor Changes + +- 12bbd748c: Removes the Prometheus integration from `backend-common`. + + Rational behind this change is to keep the metrics integration of Backstage + generic. Instead of directly relying on Prometheus, Backstage will expose + metrics in a generic way. Integrators can then export the metrics in their + desired format. For example using Prometheus. + + To keep the existing behavior, you need to integrate Prometheus in your + backend: + + First, add a dependency on `express-prom-bundle` and `prom-client` to your backend. + + ```diff + // packages/backend/package.json + "dependencies": { + + "express-prom-bundle": "^6.1.0", + + "prom-client": "^12.0.0", + ``` + + Then, add a handler for metrics and a simple instrumentation for the endpoints. + + ```typescript + // packages/backend/src/metrics.ts + import { useHotCleanup } from '@backstage/backend-common'; + import { RequestHandler } from 'express'; + import promBundle from 'express-prom-bundle'; + import prom from 'prom-client'; + import * as url from 'url'; + + const rootRegEx = new RegExp('^/([^/]*)/.*'); + const apiRegEx = new RegExp('^/api/([^/]*)/.*'); + + export function normalizePath(req: any): string { + const path = url.parse(req.originalUrl || req.url).pathname || '/'; + + // Capture /api/ and the plugin name + if (apiRegEx.test(path)) { + return path.replace(apiRegEx, '/api/$1'); + } + + // Only the first path segment at root level + return path.replace(rootRegEx, '/$1'); + } + + /** + * Adds a /metrics endpoint, register default runtime metrics and instrument the router. + */ + export function metricsHandler(): RequestHandler { + // We can only initialize the metrics once and have to clean them up between hot reloads + useHotCleanup(module, () => prom.register.clear()); + + return promBundle({ + includeMethod: true, + includePath: true, + // Using includePath alone is problematic, as it will include path labels with high + // cardinality (e.g. path params). Instead we would have to template them. However, this + // is difficult, as every backend plugin might use different routes. Instead we only take + // the first directory of the path, to have at least an idea how each plugin performs: + normalizePath, + promClient: { collectDefaultMetrics: {} }, + }); + } + ``` + + Last, extend your router configuration with the `metricsHandler`: + + ```diff + +import { metricsHandler } from './metrics'; + + ... + + const service = createServiceBuilder(module) + .loadConfig(config) + .addRouter('', await healthcheck(healthcheckEnv)) + + .addRouter('', metricsHandler()) + .addRouter('/api', apiRouter); + ``` + + Your Prometheus metrics will be available at the `/metrics` endpoint. + +### Patch Changes + +- 38e24db00: Move the core url and auth logic to integration for the four major providers +- Updated dependencies [38e24db00] +- Updated dependencies [b8ecf6f48] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/integration@0.1.3 + - @backstage/config@0.1.2 + +## 0.3.3 + +### Patch Changes + +- 612368274: Allow the `backend.listen.port` config to be both a number or a string. +- Updated dependencies [4e7091759] +- Updated dependencies [b4488ddb0] + - @backstage/config-loader@0.4.0 + ## 0.3.2 ### Patch Changes diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 3e21235c63..74c199e737 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -32,17 +32,17 @@ export interface Config { port?: string | number; }; - /** HTTPS configuration for the backend. If omitted the backend will serve HTTP */ - https?: { - /** Certificate configuration or parameters for generating a self-signed certificate */ - certificate?: - | { - /** Algorithm to use to generate a self-signed certificate */ - algorithm: string; - keySize?: number; - days?: number; - } - | { + /** + * HTTPS configuration for the backend. If omitted the backend will serve HTTP. + * + * Setting this to `true` will cause self-signed certificates to be generated, which + * can be useful for local development or other non-production scenarios. + */ + https?: + | true + | { + /** Certificate configuration */ + certificate?: { /** PEM encoded certificate. Use $file to load in a file */ cert: string; /** @@ -51,7 +51,7 @@ export interface Config { */ key: string; }; - }; + }; /** Database connection configuration, select database type using the `client` field */ database: @@ -79,6 +79,26 @@ export interface Config { optionsSuccessStatus?: number; }; + /** + * Configuration related to URL reading, used for example for reading catalog info + * files, scaffolder templates, and techdocs content. + */ + reading?: { + /** + * A list of targets to allow outgoing requests to. Users will be able to make + * requests on behalf of the backend to the targets that are allowed by this list. + */ + allow?: Array<{ + /** + * A host to allow outgoing requests to, being either a full host or + * a subdomain wildcard pattern with a leading `*`. For example `example.com` + * and `*.example.com` are valid values, `prod.*.example.com` is not. + * The host may also contain a port, for example `example.com:8080`. + */ + host: string; + }>; + }; + /** * Content Security Policy options. * @@ -89,81 +109,4 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; }; - - /** Configuration for integrations towards various external repository provider systems */ - integrations?: { - /** Integration configuration for Azure */ - azure?: Array<{ - /** - * The hostname of the given Azure instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - - /** Integration configuration for BitBucket */ - bitbucket?: Array<{ - /** - * The hostname of the given Bitbucket instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 - */ - apiBaseUrl?: string; - /** - * The username to use for authenticated requests. - * @visibility secret - */ - username?: string; - /** - * BitBucket app password used to authenticate requests. - * @visibility secret - */ - appPassword?: string; - }>; - - /** Integration configuration for GitHub */ - github?: Array<{ - /** - * The hostname of the given GitHub instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the GitHub API, for example https://api.github.com - */ - apiBaseUrl?: string; - /** - * The base url for GitHub raw resources, for example https://raw.githubusercontent.com - */ - rawBaseUrl?: string; - }>; - - /** Integration configuration for GitLab */ - gitlab?: Array<{ - /** - * The hostname of the given GitLab instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - }; } diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index ca92f70e98..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.3.2", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,9 +30,9 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", - "@backstage/config-loader": "^0.3.0", - "@backstage/integration": "^0.1.2", + "@backstage/config": "^0.1.2", + "@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", @@ -41,17 +41,16 @@ "cors": "^2.8.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.0", + "git-url-parse": "^11.4.3", "helmet": "^4.0.0", + "isomorphic-git": "^1.8.0", "knex": "^0.21.6", "lodash": "^4.17.15", "logform": "^2.1.1", "minimist": "^1.2.5", "morgan": "^1.10.0", - "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", "tar": "^6.0.5", @@ -67,9 +66,9 @@ } }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@backstage/test-utils": "^0.1.3", - "@types/archiver": "^3.1.1", + "@backstage/cli": "^0.4.7", + "@backstage/test-utils": "^0.1.5", + "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", "@types/fs-extra": "^9.0.3", @@ -83,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/database/SingleConnection.test.ts b/packages/backend-common/src/database/SingleConnection.test.ts index 6278e4ecad..3466b0f79d 100644 --- a/packages/backend-common/src/database/SingleConnection.test.ts +++ b/packages/backend-common/src/database/SingleConnection.test.ts @@ -21,14 +21,6 @@ import { SingleConnectionDatabaseManager } from './SingleConnection'; jest.mock('./connection'); describe('SingleConnectionDatabaseManager', () => { - const createConfig = (data: any) => - ConfigReader.fromConfigs([ - { - context: '', - data, - }, - ]); - const defaultConfigOptions = { backend: { database: { @@ -42,7 +34,7 @@ describe('SingleConnectionDatabaseManager', () => { }, }, }; - const defaultConfig = () => createConfig(defaultConfigOptions); + const defaultConfig = () => new ConfigReader(defaultConfigOptions); // This is similar to the ts-jest `mocked` helper. const mocked = (f: Function) => f as jest.Mock; diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index de0b0e40dc..9810ff9bef 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -18,19 +18,11 @@ import { ConfigReader } from '@backstage/config'; import { createDatabaseClient } from './connection'; describe('database connection', () => { - const createConfig = (data: any) => - ConfigReader.fromConfigs([ - { - context: '', - data, - }, - ]); - describe('createDatabaseClient', () => { it('returns a postgres connection', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', connection: { host: 'acme', @@ -46,7 +38,7 @@ describe('database connection', () => { it('returns an sqlite connection', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'sqlite3', connection: ':memory:', }), @@ -57,7 +49,7 @@ describe('database connection', () => { it('tries to create a mysql connection as a passthrough', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'mysql', connection: { host: '127.0.0.1', @@ -73,7 +65,7 @@ describe('database connection', () => { it('accepts overrides', () => { expect( createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', connection: { host: 'acme', @@ -94,7 +86,7 @@ describe('database connection', () => { it('throws an error without a client', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ connection: '', }), ), @@ -104,7 +96,7 @@ describe('database connection', () => { it('throws an error without a connection', () => { expect(() => createDatabaseClient( - createConfig({ + new ConfigReader({ client: 'pg', }), ), diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts index 8d139e4481..59c135f309 100644 --- a/packages/backend-common/src/database/postgres.test.ts +++ b/packages/backend-common/src/database/postgres.test.ts @@ -34,15 +34,7 @@ describe('postgres', () => { 'postgresql://foo:bar@acme:5432/foodb'; const createConfig = (connection: any): Config => - ConfigReader.fromConfigs([ - { - context: '', - data: { - client: 'pg', - connection, - }, - }, - ]); + new ConfigReader({ client: 'pg', connection }); describe('buildPgDatabaseConfig', () => { it('builds a postgres config', () => { diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts index a3ab331c2d..a6b8e5d84d 100644 --- a/packages/backend-common/src/database/sqlite3.test.ts +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -22,15 +22,7 @@ import { describe('sqlite3', () => { const createConfig = (connection: any) => - ConfigReader.fromConfigs([ - { - context: '', - data: { - client: 'sqlite3', - connection, - }, - }, - ]); + new ConfigReader({ client: 'sqlite3', connection }); describe('buildSqliteDatabaseConfig', () => { it('buidls a string connection', () => { diff --git a/packages/backend-common/src/errors.ts b/packages/backend-common/src/errors.ts index b68dc8e3f0..39703127e6 100644 --- a/packages/backend-common/src/errors.ts +++ b/packages/backend-common/src/errors.ts @@ -75,3 +75,8 @@ export class NotFoundError extends CustomErrorBase {} * resource. */ export class ConflictError extends CustomErrorBase {} + +/** + * The requested resource has not changed since last request. + */ +export class NotModifiedError extends CustomErrorBase {} diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index e968edef69..3f04f32b34 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -24,3 +24,4 @@ export * from './reading'; export * from './service'; export * from './paths'; export * from './hot'; +export * from './scm'; diff --git a/packages/backend-common/src/logging/formats.ts b/packages/backend-common/src/logging/formats.ts index 5f00448744..4f7949f115 100644 --- a/packages/backend-common/src/logging/formats.ts +++ b/packages/backend-common/src/logging/formats.ts @@ -17,19 +17,23 @@ import * as winston from 'winston'; import { TransformableInfo } from 'logform'; const coloredTemplate = (info: TransformableInfo) => { - const { timestamp, level, message, plugin, service } = info; + const { timestamp, level, message, plugin, service, ...fields } = info; const colorizer = winston.format.colorize(); const prefix = plugin || service; const timestampColor = colorizer.colorize('timestamp', timestamp); const prefixColor = colorizer.colorize('prefix', prefix); - return `${timestampColor} ${prefixColor} ${level} ${message}`; + const extraFields = Object.entries(fields) + .map(([key, value]) => `${colorizer.colorize('field', `${key}`)}=${value}`) + .join(' '); + + return `${timestampColor} ${prefixColor} ${level} ${message} ${extraFields}`; }; export const coloredFormat = winston.format.combine( winston.format.timestamp(), winston.format.colorize({ - colors: { timestamp: 'dim', prefix: 'blue' }, + colors: { timestamp: 'dim', prefix: 'blue', field: 'cyan' }, }), winston.format.printf(coloredTemplate), ); 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 ab97d1b073..20f8feba42 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -20,9 +20,10 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; -import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader'; +import { AzureUrlReader } from './AzureUrlReader'; import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError } from '../errors'; const logger = getVoidLogger(); @@ -111,13 +112,13 @@ describe('AzureUrlReader', () => { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', }, { url: 'com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', }, { url: '', @@ -139,7 +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,46 +159,73 @@ 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'); }); }); - - describe('getDownloadUrl', () => { - it('do not add scopePath if no path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository', - ); - - expect(result.searchParams.get('scopePath')).toBeNull(); - }); - - it('add scopePath if a path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', - ); - expect(result.searchParams.get('scopePath')).toEqual('docs'); - }); - }); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ad990d1d5d..578db2ac92 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -17,11 +17,14 @@ import { AzureIntegrationConfig, readAzureIntegrationConfigs, + getAzureFileFetchUrl, + getAzureDownloadUrl, + getAzureRequestOptions, + getAzureCommitsUrl, } from '@backstage/integration'; import fetch from 'cross-fetch'; import { Readable } from 'stream'; -import parseGitUri from 'git-url-parse'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReaderFactory, ReadTreeOptions, @@ -30,28 +33,6 @@ import { } from './types'; import { ReadTreeResponseFactory } from './tree'; -export function getDownloadUrl(url: string): URL { - const { - name: repoName, - owner: project, - organization, - protocol, - resource, - filepath, - } = parseGitUri(url); - - // scopePath will limit the downloaded content - // /docs will only download the docs folder and everything below it - // /docs/index.md will only download index.md but put it in the root of the archive - const scopePath = filepath - ? `&scopePath=${encodeURIComponent(filepath)}` - : ''; - - return new URL( - `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`, - ); -} - export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const configs = readAzureIntegrationConfigs( @@ -67,20 +48,14 @@ export class AzureUrlReader implements UrlReader { constructor( private readonly options: AzureIntegrationConfig, private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, - ) { - if (options.host !== 'dev.azure.com') { - throw Error( - `Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`, - ); - } - } + ) {} async read(url: string): Promise { - const builtUrl = this.buildRawUrl(url); + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch(builtUrl, getAzureRequestOptions(this.options)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -101,98 +76,46 @@ export class AzureUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const response = await fetch( - getDownloadUrl(url).toString(), - this.getRequestOptions({ Accept: 'application/zip' }), + // TODO: Support filepath based reading tree feature like other providers + + // Get latest commit SHA + + const commitsAzureResponse = await fetch( + getAzureCommitsUrl(url), + getAzureRequestOptions(this.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, }); } - // Converts - // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents - // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - project, - srcKeyword, - repoName, - ] = url.pathname.split('/'); - - const path = url.searchParams.get('path') || ''; - const ref = url.searchParams.get('version')?.substr(2); - - if ( - url.hostname !== 'dev.azure.com' || - empty !== '' || - userOrOrg === '' || - project === '' || - srcKeyword !== '_git' || - repoName === '' || - path === '' || - ref === '' - ) { - throw new Error('Wrong Azure Devops URL or Invalid file path'); - } - - // transform to api - url.pathname = [ - empty, - userOrOrg, - project, - '_apis', - 'git', - 'repositories', - repoName, - 'items', - ].join('/'); - - const queryParams = [`path=${path}`]; - - if (ref) { - queryParams.push(`version=${ref}`); - } - - url.search = queryParams.join('&'); - - url.protocol = 'https'; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private getRequestOptions(additionalHeaders?: { - [key: string]: string; - }): RequestInit { - const headers: HeadersInit = additionalHeaders ?? {}; - - if (this.options.token) { - headers.Authorization = `Basic ${Buffer.from( - `:${this.options.token}`, - 'utf8', - ).toString('base64')}`; - } - - return { headers }; - } - toString() { const { host, token } = this.options; return `azure{host=${host},authed=${Boolean(token)}}`; diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 01744db28a..9661368b5e 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -14,105 +14,195 @@ * limitations under the License. */ -import { BitbucketIntegrationConfig } from '@backstage/integration'; -import { - BitbucketUrlReader, - getApiRequestOptions, - getApiUrl, -} from './BitbucketUrlReader'; +import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +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'; + +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('getApiRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('Bearer A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - - it('insert basic auth when needed', () => { - const withUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - username: 'some-user', - appPassword: 'my-secret', - }; - const withoutUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withUsernameAndPassword).headers as any) - .Authorization, - ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); - expect( - (getApiRequestOptions(withoutUsernameAndPassword).headers as any) - .Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - it('happy path for Bitbucket Cloud', () => { - const config: BitbucketIntegrationConfig = { - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }; - expect( - getApiUrl( - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', - ), - ); - }); - it('happy path for Bitbucket Server', () => { - const config: BitbucketIntegrationConfig = { - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', - }; - expect( - getApiUrl( - 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml', - ), - ); - }); - }); - describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new BitbucketUrlReader({ - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }); await expect( - processor.read('https://not.bitbucket.com/apa'), + bitbucketProcessor.read('https://not.bitbucket.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path', ); }); }); + + describe('readTree', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + const repoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-repo-with-commit-hash.zip', + ), + ); + + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), + rest.get( + 'https://bitbucket.org/backstage/mock/get/master.zip', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.zip', + ), + ctx.body(privateBitbucketRepoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('returns the wanted files from an archive', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock/src/master', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(2); + const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[1].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + }); + + it('uses private bitbucket host', async () => { + const response = await hostedBitbucketProcessor.readTree( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('returns the wanted files from an archive with a subpath', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('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 9694c1d987..e9727e04cf 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -16,68 +16,23 @@ import { BitbucketIntegrationConfig, + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, readBitbucketIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; - -export function getApiRequestOptions( - provider: BitbucketIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `Bearer ${provider.token}`; - } else if (provider.username && provider.appPassword) { - headers.Authorization = `Basic ${Buffer.from( - `${provider.username}:${provider.appPassword}`, - 'utf8', - ).toString('base64')}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml -// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml -export function getApiUrl( - target: string, - provider: BitbucketIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - if ( - !owner || - !name || - (filepathtype !== 'browse' && - filepathtype !== 'raw' && - filepathtype !== 'src') - ) { - throw new Error('Invalid Bitbucket URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - - if (provider.host === 'bitbucket.org') { - if (!ref) { - throw new Error('Invalid Bitbucket URL or file path'); - } - return new URL( - `${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`, - ); - } - return new URL( - `${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} +import parseGitUrl from 'git-url-parse'; +import { Readable } from 'stream'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + UrlReader, +} from './types'; /** * A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as @@ -85,19 +40,23 @@ export function getApiUrl( */ export class BitbucketUrlReader implements UrlReader { private readonly config: BitbucketIntegrationConfig; + private readonly treeResponseFactory: ReadTreeResponseFactory; - static factory: ReaderFactory = ({ config }) => { + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const configs = readBitbucketIntegrationConfigs( config.getOptionalConfigArray('integrations.bitbucket') ?? [], ); return configs.map(provider => { - const reader = new BitbucketUrlReader(provider); + const reader = new BitbucketUrlReader(provider, { treeResponseFactory }); const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; - constructor(config: BitbucketIntegrationConfig) { + constructor( + config: BitbucketIntegrationConfig, + deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { const { host, apiBaseUrl, token, username, appPassword } = config; if (!apiBaseUrl) { @@ -113,12 +72,12 @@ export class BitbucketUrlReader implements UrlReader { } this.config = config; + this.treeResponseFactory = deps.treeResponseFactory; } async read(url: string): Promise { - const bitbucketUrl = getApiUrl(url, this.config); - - const options = getApiRequestOptions(this.config); + const bitbucketUrl = getBitbucketFileFetchUrl(url, this.config); + const options = getBitbucketRequestOptions(this.config); let response: Response; try { @@ -138,8 +97,58 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { - throw new Error('BitbucketUrlReader does not implement readTree'); + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { filepath } = parseGitUrl(url); + + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } + + const downloadUrl = await getBitbucketDownloadUrl(url, this.config); + const archiveBitbucketResponse = await fetch( + downloadUrl, + getBitbucketRequestOptions(this.config), + ); + if (!archiveBitbucketResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`; + if (archiveBitbucketResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + // 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 await this.treeResponseFactory.fromZipArchive({ + stream: (archiveBitbucketResponse.body as unknown) as Readable, + path: `${archiveFileName}/${filepath}`, + etag: lastCommitShortHash, + filter: options?.filter, + }); } toString() { @@ -150,4 +159,37 @@ export class BitbucketUrlReader implements UrlReader { } return `bitbucket{host=${host},authed=${authed}}`; } + + private async getLastCommitShortHash(url: string): Promise { + const { name: repoName, owner: project, ref } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketDefaultBranch(url, this.config); + } + const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; + + const commitsResponse = await fetch( + commitsApiUrl, + getBitbucketRequestOptions(this.config), + ); + if (!commitsResponse.ok) { + const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; + if (commitsResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commits = await commitsResponse.json(); + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].hash + ) { + return commits.values[0].hash.substring(0, 12); + } + throw new Error(`Failed to read response from ${commitsApiUrl}`); + } } diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts new file mode 100644 index 0000000000..8dc4aba29b --- /dev/null +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { getVoidLogger } from '../logging'; +import { FetchUrlReader } from './FetchUrlReader'; +import { ReadTreeResponseFactory } from './tree'; + +describe('FetchUrlReader', () => { + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('factory should create a single entry with a predicate that matches config', async () => { + const entries = FetchUrlReader.factory({ + config: new ConfigReader({ + backend: { + reading: { + allow: [ + { host: 'example.com' }, + { host: 'example.com:700' }, + { host: '*.examples.org' }, + { host: '*.examples.org:700' }, + ], + }, + }, + }), + logger: getVoidLogger(), + treeResponseFactory: ReadTreeResponseFactory.create({ + config: new ConfigReader({}), + }), + }); + + expect(entries.length).toBe(1); + const [{ predicate }] = entries; + + expect(predicate(new URL('https://example.com/test'))).toBe(true); + expect(predicate(new URL('https://a.example.com/test'))).toBe(false); + expect(predicate(new URL('https://example.com:600/test'))).toBe(false); + expect(predicate(new URL('https://a.example.com:600/test'))).toBe(false); + expect(predicate(new URL('https://example.com:700/test'))).toBe(true); + expect(predicate(new URL('https://a.example.com:700/test'))).toBe(false); + expect(predicate(new URL('https://other.com/test'))).toBe(false); + expect(predicate(new URL('https://examples.org/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org/test'))).toBe(true); + expect(predicate(new URL('https://a.b.examples.org/test'))).toBe(true); + expect(predicate(new URL('https://examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://a.b.examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://examples.org:700/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true); + expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true); + }); +}); diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 1d1784590c..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 eb87d339ea..080e8b1d5b 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,215 +15,207 @@ */ import { ConfigReader } from '@backstage/config'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +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 { - getApiRequestOptions, - getApiUrl, - getRawRequestOptions, - getRawUrl, - GithubUrlReader, -} from './GithubUrlReader'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { GithubUrlReader } from './GithubUrlReader'; import { ReadTreeResponseFactory } from './tree'; const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const mockCredentialsProvider = ({ + getCredentials: jest.fn().mockResolvedValue({ headers: {} }), +} as unknown) as GithubCredentialsProvider; + +const githubProcessor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + +const gheProcessor = new GithubUrlReader( + { + host: 'ghe.github.com', + apiBaseUrl: 'https://ghe.github.com/api/v3', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + describe('GithubUrlReader', () => { - describe('getApiRequestOptions', () => { - it('sets the correct API version', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect((getApiRequestOptions(config).headers as any).Accept).toEqual( - 'application/vnd.github.v3.raw', - ); - }); + const worker = setupServer(); - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); + msw.setupDefaultHandlers(worker); - describe('getRawRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - }; - expect( - (getRawRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getRawRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }; - expect( - getApiUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - }); - - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - apiBaseUrl: 'https://ghe.mycompany.net/api/v3', - }; - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - }); - }); - - describe('getRawUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { - host: 'github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }; - expect( - getRawUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', - ), - ); - }); - - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - rawBaseUrl: 'https://ghe.mycompany.net/raw', - }; - expect( - getRawUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'), - ); - }); + beforeEach(() => { + jest.clearAllMocks(); }); describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); await expect( - processor.read('https://not.github.com/apa'), + githubProcessor.read('https://not.github.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path', ); }); }); + 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(); @@ -235,33 +227,49 @@ 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 }, + 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/api/v3/repos/backstage/mock/tarball/etag123abc', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ); + }, + ), ); - await expect( - processor.readTree('https://github.com/backstage/mock'), - ).rejects.toThrow( - 'GitHub URL must contain branch to be able to fetch tree', + await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main', ); }); - 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', + 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(); @@ -271,5 +279,64 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + + it('returns the wanted files from an archive with a subpath', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('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 907f2ada7a..6c7cefe2ef 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -17,11 +17,13 @@ import { GitHubIntegrationConfig, readGitHubIntegrationConfigs, + getGitHubFileFetchUrl, + 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, @@ -30,92 +32,6 @@ import { UrlReader, } from './types'; -export function getApiRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -export function getRawRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/path/to/c.yaml -// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname -export function getApiUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/c.yaml -// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml -export function getRawUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - /** * A processor that adds the ability to read files from GitHub v3 APIs, such as * the one exposed by GitHub itself. @@ -126,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 }; }); @@ -134,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( @@ -144,18 +67,18 @@ export class GithubUrlReader implements UrlReader { } async read(url: string): Promise { - const useApi = - this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl); - const ghUrl = useApi - ? getApiUrl(url, this.config) - : getRawUrl(url, this.config); - const options = useApi - ? getApiRequestOptions(this.config) - : getRawRequestOptions(this.config); - + const ghUrl = getGitHubFileFetchUrl(url, this.config); + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); let response: Response; try { - response = await fetch(ghUrl.toString(), options); + response = await fetch(ghUrl.toString(), { + headers: { + ...headers, + Accept: 'application/vnd.github.v3.raw', + }, + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -175,43 +98,106 @@ export class GithubUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - ref, - protocol, - source, - 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}://${source}/${full_name}/archive/${ref}.tar.gz`, - ).toString(), + // 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 3a76c0631b..c0736f769d 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -14,13 +14,16 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { ConfigReader } from '@backstage/config'; +import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError, NotFoundError } from '../errors'; const logger = getVoidLogger(); @@ -28,107 +31,315 @@ 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); - beforeEach(() => { - worker.use( - rest.get('*/api/v4/projects/:name', (_, res, ctx) => - res(ctx.status(200), ctx.json({ id: 12345 })), - ), - rest.get('*', (req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.getAllHeaders(), - }), + describe('implementation', () => { + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), ), - ), - ); - }); - - const createConfig = (token?: string) => - new ConfigReader( - { - integrations: { gitlab: [{ host: 'gitlab.com', token }] }, - }, - 'test-config', - ); - - it.each([ - // Project URLs - { - url: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - config: createConfig(), - response: expect.objectContaining({ - url: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - headers: expect.objectContaining({ - 'private-token': '', - }), - }), - }, - { - url: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - config: createConfig('0123456789'), - response: expect.objectContaining({ - url: - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - headers: expect.objectContaining({ - 'private-token': '0123456789', - }), - }), - }, - { - url: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup - config: createConfig(), - response: expect.objectContaining({ - url: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - }), - }, - - // Raw URLs - { - url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', - config: createConfig(), - response: expect.objectContaining({ - url: 'https://gitlab.example.com/a/b/raw/master/c.yaml', - }), - }, - ])('should handle happy path %#', async ({ url, config, response }) => { - const [{ reader }] = GitlabUrlReader.factory({ - config, - logger, - treeResponseFactory, + rest.get('*', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.getAllHeaders(), + }), + ), + ), + ); }); - const data = await reader.read(url); - const res = await JSON.parse(data.toString('utf-8')); - expect(res).toEqual(response); - }); + const createConfig = (token?: string) => + new ConfigReader( + { + integrations: { gitlab: [{ host: 'gitlab.com', token }] }, + }, + 'test-config', + ); - it.each([ - { - url: '', - config: createConfig(''), - error: - "Invalid type in config for key 'integrations.gitlab[0].token' in 'test-config', got empty-string, wanted string", - }, - ])('should handle error path %#', async ({ url, config, error }) => { - await expect(async () => { + it.each([ + // Project URLs + { + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '', + }), + }), + }, + { + url: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig('0123456789'), + response: expect.objectContaining({ + url: + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '0123456789', + }), + }), + }, + { + url: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }), + }, + + // Raw URLs + { + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }), + }, + ])('should handle happy path %#', async ({ url, config, response }) => { const [{ reader }] = GitlabUrlReader.factory({ config, logger, treeResponseFactory, }); - await reader.read(url); - }).rejects.toThrow(error); + + const data = await reader.read(url); + const res = await JSON.parse(data.toString('utf-8')); + expect(res).toEqual(response); + }); + + it.each([ + { + url: '', + config: createConfig(''), + error: + "Invalid type in config for key 'integrations.gitlab[0].token' in 'test-config', got empty-string, wanted string", + }, + ])('should handle error path %#', async ({ url, config, error }) => { + await expect(async () => { + const [{ reader }] = GitlabUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); + await reader.read(url); + }).rejects.toThrow(error); + }); + }); + + describe('readTree', () => { + const archiveBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + ); + + const projectGitlabApiResponse = { + id: 11111111, + default_branch: 'main', + }; + + const branchGitlabApiResponse = { + commit: { + id: 'sha123abc', + }, + }; + + beforeEach(() => { + worker.use( + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + ); + }); + + it('returns the wanted files from an archive', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main', + ); + + const files = await response.files(); + expect(files.length).toBe(2); + + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('returns the wanted files from hosted gitlab', async () => { + worker.use( + rest.get( + 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + ); + + const response = await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/tree/main/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('returns the wanted files from an archive with a subpath', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('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 e2d3edfea2..654f4f9a85 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -15,42 +15,50 @@ */ import { + getGitLabFileFetchUrl, + getGitLabRequestOptions, GitLabIntegrationConfig, readGitLabIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + UrlReader, +} from './types'; +import parseGitUrl from 'git-url-parse'; +import { Readable } from 'stream'; export class GitlabUrlReader implements UrlReader { - static factory: ReaderFactory = ({ config }) => { + private readonly treeResponseFactory: ReadTreeResponseFactory; + + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const configs = readGitLabIntegrationConfigs( config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - return configs.map(options => { - const reader = new GitlabUrlReader(options); - const predicate = (url: URL) => url.host === options.host; + return 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) {} + constructor( + private readonly config: GitLabIntegrationConfig, + deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { + this.treeResponseFactory = deps.treeResponseFactory; + } async read(url: string): Promise { - // TODO(Rugvip): merged the old GitlabReaderProcessor in here and used - // the existence of /~/blob/ to switch the logic. Don't know if this - // makes sense and it might require some more work. - let builtUrl: URL; - if (url.includes('/-/blob/')) { - const projectID = await this.getProjectID(url); - builtUrl = this.buildProjectUrl(url, projectID); - } else { - builtUrl = this.buildRawUrl(url); - } + const builtUrl = await getGitLabFileFetchUrl(url, this.config); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch(builtUrl, getGitLabRequestOptions(this.config)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -66,115 +74,106 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { - throw new Error('GitlabUrlReader does not implement readTree'); - } + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { ref, full_name, filepath } = parseGitUrl(url); - // Converts - // from: https://gitlab.example.com/a/b/blob/master/c.yaml - // to: https://gitlab.example.com/a/b/raw/master/c.yaml - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - repoName, - blobKeyword, - ...restOfPath - ] = url.pathname.split('/'); - - if ( - empty !== '' || - userOrOrg === '' || - repoName === '' || - blobKeyword !== 'blob' || - !restOfPath.join('/').match(/\.yaml$/) - ) { - throw new Error('Wrong GitLab URL'); + // 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 (!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(); - // Replace 'blob' with 'raw' - url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join( - '/', + // ref is an empty string if no branch is set in provided url to readTree. + const branch = ref || projectGitlabResponseJson.default_branch; + + // Fetch the latest commit in the provided or default branch to compare against + // the provided sha. + const branchGitlabResponse = await fetch( + new URL( + `${this.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 ', ); - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); } - } - - // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch - private buildProjectUrl(target: string, projectID: Number): URL { - try { - const url = new URL(target); - - const branchAndFilePath = url.pathname.split('/-/blob/')[1]; - - const [branch, ...filePath] = branchAndFilePath.split('/'); - - url.pathname = [ - '/api/v4/projects', - projectID, - 'repository/files', - encodeURIComponent(filePath.join('/')), - 'raw', - ].join('/'); - url.search = `?ref=${branch}`; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private async getProjectID(target: string): Promise { - const url = new URL(target); - - if ( - // absPaths to gitlab files should contain /-/blob - // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - !url.pathname.match(/\/\-\/blob\//) - ) { - throw new Error('Please provide full path to yaml file from Gitlab'); - } - try { - const repo = url.pathname.split('/-/blob/')[0]; - - // Find ProjectID from url - // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' - // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' - const repoIDLookup = new URL( - `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( - repo.replace(/^\//, ''), - )}`, + const 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 response = await fetch( - repoIDLookup.toString(), - this.getRequestOptions(), - ); - const projectIDJson = await response.json(); - const projectID: Number = projectIDJson.id; - - return projectID; - } catch (e) { - throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`); } - } - private getRequestOptions(): RequestInit { - return { - headers: { - ['PRIVATE-TOKEN']: this.options.token ?? '', - }, - }; + 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__/bitbucket-repo-with-commit-hash.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip new file mode 100644 index 0000000000..135860afd3 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip new file mode 100644 index 0000000000..be6b20d127 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip new file mode 100644 index 0000000000..884ec20004 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz new file mode 100644 index 0000000000..291690b447 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.zip b/packages/backend-common/src/reading/__fixtures__/mock-main.zip new file mode 100644 index 0000000000..beee59d3a0 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz deleted file mode 100644 index 7a8e9902a2..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz and /dev/null differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.zip b/packages/backend-common/src/reading/__fixtures__/repo.zip deleted file mode 100644 index f66bf2d612..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.zip and /dev/null differ diff --git a/packages/backend-common/src/reading/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/scm/git.test.ts b/packages/backend-common/src/scm/git.test.ts new file mode 100644 index 0000000000..9af080a782 --- /dev/null +++ b/packages/backend-common/src/scm/git.test.ts @@ -0,0 +1,321 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +jest.mock('isomorphic-git'); +jest.mock('isomorphic-git/http/node'); +jest.mock('fs-extra'); + +import * as isomorphic from 'isomorphic-git'; +import { Git } from './git'; +import http from 'isomorphic-git/http/node'; +import fs from 'fs-extra'; + +describe('Git', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + describe('add', () => { + it('should call isomorphic-git add with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const filepath = 'mockfile/path'; + + await git.add({ dir, filepath }); + + expect(isomorphic.add).toHaveBeenCalledWith({ + fs, + dir, + filepath, + }); + }); + }); + + describe('addRemote', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const remote = 'origin'; + const url = 'git@github.com/something/sads'; + + await git.addRemote({ dir, remote, url }); + + expect(isomorphic.addRemote).toHaveBeenCalledWith({ + fs, + dir, + remote, + url, + }); + }); + }); + + describe('commit', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const git = Git.fromAuth({}); + const dir = 'mockdirectory'; + const message = 'Inital Commit'; + const author = { + name: 'author', + email: 'test@backstage.io', + }; + const committer = { + name: 'comitter', + email: 'test@backstage.io', + }; + + await git.commit({ dir, message, author, committer }); + + expect(isomorphic.commit).toHaveBeenCalledWith({ + fs, + dir, + message, + author, + committer, + }); + }); + }); + + describe('clone', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const url = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.clone({ url, dir }); + + expect(isomorphic.clone).toHaveBeenCalledWith({ + fs, + http, + url, + dir, + singleBranch: true, + depth: 1, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const url = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.clone({ url, dir }); + + const { onAuth } = ((isomorphic.clone as unknown) as jest.Mock< + typeof isomorphic['clone'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('currentBranch', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const fullName = true; + const git = Git.fromAuth({}); + + await git.currentBranch({ dir, fullName }); + + expect(isomorphic.currentBranch).toHaveBeenCalledWith({ + fs, + dir, + fullname: true, + }); + + await git.currentBranch({ dir }); + + expect(isomorphic.currentBranch).toHaveBeenCalledWith({ + fs, + dir, + fullname: false, + }); + }); + }); + + describe('fetch', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const remote = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.fetch({ remote, dir }); + + expect(isomorphic.fetch).toHaveBeenCalledWith({ + fs, + http, + remote, + dir, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const remote = 'http://github.com/some/repo'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.fetch({ remote, dir }); + + const { onAuth } = ((isomorphic.fetch as unknown) as jest.Mock< + typeof isomorphic['fetch'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('init', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + + const git = Git.fromAuth({}); + + await git.init({ dir }); + + expect(isomorphic.init).toHaveBeenCalledWith({ + fs, + dir, + }); + }); + }); + + describe('merge', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const author = { + name: 'author', + email: 'test@backstage.io', + }; + const committer = { + name: 'comitter', + email: 'test@backstage.io', + }; + const theirs = 'master'; + const ours = 'production'; + + const git = Git.fromAuth({}); + + await git.merge({ dir, theirs, ours, author, committer }); + + expect(isomorphic.merge).toHaveBeenCalledWith({ + fs, + dir, + ours, + theirs, + author, + committer, + }); + }); + }); + + describe('push', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const remote = 'origin'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.push({ dir, remote }); + + expect(isomorphic.push).toHaveBeenCalledWith({ + fs, + http, + remote, + dir, + onProgress: expect.any(Function), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: expect.any(Function), + }); + }); + it('should pass a function that returns the authorization as the onAuth handler', async () => { + const remote = 'origin'; + const dir = '/some/mock/dir'; + const auth = { + username: 'blob', + password: 'hunter2', + }; + const git = Git.fromAuth(auth); + + await git.push({ remote, dir }); + + const { onAuth } = ((isomorphic.push as unknown) as jest.Mock< + typeof isomorphic['push'] + >).mock.calls[0][0]!; + + expect(onAuth()).toEqual(auth); + }); + }); + + describe('readCommit', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const sha = 'as43bd7'; + + const git = Git.fromAuth({}); + + await git.readCommit({ dir, sha }); + + expect(isomorphic.readCommit).toHaveBeenCalledWith({ + fs, + dir, + oid: sha, + }); + }); + }); + + describe('resolveRef', () => { + it('should call isomorphic-git with the correct arguments', async () => { + const dir = '/some/mock/dir'; + const ref = 'as43bd7'; + + const git = Git.fromAuth({}); + + await git.resolveRef({ dir, ref }); + + expect(isomorphic.resolveRef).toHaveBeenCalledWith({ + fs, + dir, + ref, + }); + }); + }); +}); diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts new file mode 100644 index 0000000000..b0a1df6541 --- /dev/null +++ b/packages/backend-common/src/scm/git.ts @@ -0,0 +1,251 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import git, { + ProgressCallback, + MergeResult, + ReadCommitResult, +} from 'isomorphic-git'; +import http from 'isomorphic-git/http/node'; +import fs from 'fs-extra'; +import { Logger } from 'winston'; + +/* +provider username password +GitHub token 'x-oauth-basic' +GitHub App token 'x-access-token' +BitBucket 'x-token-auth' token +GitLab 'oauth2' token +From : https://isomorphic-git.org/docs/en/onAuth + +Azure 'notempty' token +*/ +export class Git { + private constructor( + private readonly config: { + username?: string; + password?: string; + logger?: Logger; + }, + ) {} + + async add({ + dir, + filepath, + }: { + dir: string; + filepath: string; + }): Promise { + this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`); + + return git.add({ fs, dir, filepath }); + } + + async addRemote({ + dir, + url, + remote, + }: { + dir: string; + remote: string; + url: string; + }): Promise { + this.config.logger?.info( + `Creating new remote {dir=${dir},remote=${remote},url=${url}}`, + ); + return git.addRemote({ fs, dir, remote, url }); + } + + async commit({ + dir, + message, + author, + committer, + }: { + dir: string; + message: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + }): Promise { + this.config.logger?.info( + `Committing file to repo {dir=${dir},message=${message}}`, + ); + + return git.commit({ fs, dir, message, author, committer }); + } + + async clone({ url, dir }: { url: string; dir: string }): Promise { + this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`); + return git.clone({ + fs, + http, + url, + dir, + singleBranch: true, + depth: 1, + onProgress: this.onProgressHandler(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: this.onAuth, + }); + } + + // https://isomorphic-git.org/docs/en/currentBranch + async currentBranch({ + dir, + fullName, + }: { + dir: string; + fullName?: boolean; + }): Promise { + const fullname = fullName ?? false; + return git.currentBranch({ fs, dir, fullname }) as Promise< + string | undefined + >; + } + + // https://isomorphic-git.org/docs/en/fetch + async fetch({ + dir, + remote, + }: { + dir: string; + remote?: string; + }): Promise { + const remoteValue = remote ?? 'origin'; + this.config.logger?.info( + `Fetching remote=${remoteValue} for repository {dir=${dir}}`, + ); + await git.fetch({ + fs, + http, + dir, + remote: remoteValue, + onProgress: this.onProgressHandler(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + onAuth: this.onAuth, + }); + } + + async init({ dir }: { dir: string }): Promise { + this.config.logger?.info(`Init git repository {dir=${dir}}`); + + return git.init({ + fs, + dir, + }); + } + + // https://isomorphic-git.org/docs/en/merge + async merge({ + dir, + theirs, + ours, + author, + committer, + }: { + dir: string; + theirs: string; + ours?: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + }): Promise { + this.config.logger?.info( + `Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`, + ); + + // If ours is undefined, current branch is used. + return git.merge({ + fs, + dir, + ours, + theirs, + author, + committer, + }); + } + + async push({ dir, remote }: { dir: string; remote: string }) { + this.config.logger?.info( + `Pushing directory to remote {dir=${dir},remote=${remote}}`, + ); + return git.push({ + fs, + dir, + http, + onProgress: this.onProgressHandler(), + headers: { + 'user-agent': 'git/@isomorphic-git', + }, + remote: remote, + onAuth: this.onAuth, + }); + } + + // https://isomorphic-git.org/docs/en/readCommit + async readCommit({ + dir, + sha, + }: { + dir: string; + sha: string; + }): Promise { + return git.readCommit({ fs, dir, oid: sha }); + } + + // https://isomorphic-git.org/docs/en/resolveRef + async resolveRef({ + dir, + ref, + }: { + dir: string; + ref: string; + }): Promise { + return git.resolveRef({ fs, dir, ref }); + } + + private onAuth = () => ({ + username: this.config.username, + password: this.config.password, + }); + + private onProgressHandler = (): ProgressCallback => { + let currentPhase = ''; + + return event => { + if (currentPhase !== event.phase) { + currentPhase = event.phase; + this.config.logger?.info(event.phase); + } + const total = event.total + ? `${Math.round((event.loaded / event.total) * 100)}%` + : event.loaded; + this.config.logger?.debug(`status={${event.phase},total={${total}}}`); + }; + }; + + static fromAuth = ({ + username, + password, + logger, + }: { + username?: string; + password?: string; + logger?: Logger; + }) => new Git({ username, password, logger }); +} diff --git a/plugins/sentry-backend/src/index.ts b/packages/backend-common/src/scm/index.ts similarity index 94% rename from plugins/sentry-backend/src/index.ts rename to packages/backend-common/src/scm/index.ts index 7612c392a2..e967fffb44 100644 --- a/plugins/sentry-backend/src/index.ts +++ b/packages/backend-common/src/scm/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './service/router'; +export { Git } from './git'; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index b47f4ef7e5..9c5ac20fa3 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -39,7 +39,6 @@ import { readHttpsSettings, } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; -import { metricsHandler } from './metrics'; export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces @@ -53,7 +52,7 @@ const DEFAULT_CSP = { 'frame-ancestors': ["'self'"], 'img-src': ["'self'", 'data:'], 'object-src': ["'none'"], - 'script-src': ["'self'"], + 'script-src': ["'self'", "'unsafe-eval'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], 'upgrade-insecure-requests': [] as string[], @@ -66,7 +65,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { private corsOptions: cors.CorsOptions | undefined; private cspOptions: Record | undefined; private httpsSettings: HttpsSettings | undefined; - private enableMetrics: boolean = true; private routers: [string, Router][]; // Reference to the module where builder is created - needed for hot module // reloading @@ -109,9 +107,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.httpsSettings = httpsSettings; } - // For now, configuration of metrics is a simple boolean and active by default - this.enableMetrics = backendConfig.getOptionalBoolean('metrics') !== false; - return this; } @@ -150,7 +145,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } - start(): Promise { + async start(): Promise { const app = express(); const { port, @@ -166,9 +161,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(cors(corsOptions)); } app.use(compression()); - if (this.enableMetrics) { - app.use(metricsHandler()); - } app.use(requestLoggingHandler()); for (const [root, route] of this.routers) { app.use(root, route); @@ -176,16 +168,16 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(notFoundHandler()); app.use(errorHandler()); + const server: http.Server = httpsSettings + ? await createHttpsServer(app, httpsSettings, logger) + : createHttpServer(app, logger); + return new Promise((resolve, reject) => { app.on('error', e => { logger.error(`Failed to start up on port ${port}, ${e}`); reject(e); }); - const server: http.Server = httpsSettings - ? createHttpsServer(app, httpsSettings, logger) - : createHttpServer(app, logger); - const stoppableServer = stoppable( server.listen(port, host, () => { logger.info(`Listening on ${host}:${port}`); diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts index 8a35f147ca..75252357d6 100644 --- a/packages/backend-common/src/service/lib/config.test.ts +++ b/packages/backend-common/src/service/lib/config.test.ts @@ -20,9 +20,7 @@ import { readCspOptions } from './config'; describe('config', () => { describe('readCspOptions', () => { it('reads valid values', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: ['value'] } } }, - ]); + const config = new ConfigReader({ csp: { key: ['value'] } }); expect(readCspOptions(config)).toEqual( expect.objectContaining({ key: ['value'], @@ -31,9 +29,7 @@ describe('config', () => { }); it('accepts false', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: false } } }, - ]); + const config = new ConfigReader({ csp: { key: false } }); expect(readCspOptions(config)).toEqual( expect.objectContaining({ key: false, @@ -42,9 +38,7 @@ describe('config', () => { }); it('rejects invalid value types', () => { - const config = ConfigReader.fromConfigs([ - { context: '', data: { csp: { key: [4] } } }, - ]); + const config = new ConfigReader({ csp: { key: [4] } }); expect(() => readCspOptions(config)).toThrow(/wanted string-array/); }); }); diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index ea1a2b0887..3a33675d5f 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -22,23 +22,8 @@ export type BaseOptions = { listenHost?: string; }; -export type CertificateOptions = { - key?: CertificateKeyOptions; - attributes?: CertificateAttributeOptions; -}; - -export type CertificateKeyOptions = { - size?: number; - algorithm?: string; - days?: number; -}; - -export type CertificateAttributeOptions = { - commonName?: string; -}; - export type HttpsSettings = { - certificate: CertificateSigningOptions | CertificateReferenceOptions; + certificate: CertificateGenerationOptions | CertificateReferenceOptions; }; export type CertificateReferenceOptions = { @@ -46,15 +31,12 @@ export type CertificateReferenceOptions = { cert: string; }; -export type CertificateSigningOptions = { - algorithm: string; - size?: number; - days?: number; - attributes?: CertificateAttributes; +export type CertificateGenerationOptions = { + hostname: string; }; export type CertificateAttributes = { - commonName?: string; + commonName: string; }; /** @@ -193,8 +175,20 @@ export function readCspOptions( * ``` */ export function readHttpsSettings(config: Config): HttpsSettings | undefined { - const cc = config.getOptionalConfig('https'); + const https = config.getOptional('https'); + if (https === true) { + const baseUrl = config.getString('baseUrl'); + let hostname; + try { + hostname = new URL(baseUrl).hostname; + } catch (error) { + throw new Error(`Invalid backend.baseUrl "${baseUrl}"`); + } + return { certificate: { hostname } }; + } + + const cc = config.getOptionalConfig('https'); if (!cc) { return undefined; } diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-common/src/service/lib/hostFactory.ts index 8fab2fd4ab..db202a84ab 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -13,12 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import fs from 'fs-extra'; +import { resolve as resolvePath, dirname } from 'path'; import express from 'express'; import * as http from 'http'; import * as https from 'https'; import { Logger } from 'winston'; import { HttpsSettings } from './config'; +const ALMOST_MONTH_IN_MS = 25 * 24 * 60 * 60 * 1000; + +const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/; + /** * Creates a Http server instance based on an Express application. * @@ -45,48 +52,155 @@ export function createHttpServer( * @returns A Https server instance * */ -export function createHttpsServer( +export async function createHttpsServer( app: express.Express, httpsSettings: HttpsSettings, logger?: Logger, -): http.Server { +): Promise { logger?.info('Initializing https server'); - const credentials: { key: string; cert: string } = { - key: '', - cert: '', - }; + let credentials: { key: string | Buffer; cert: string | Buffer }; - const signingOptions: any = httpsSettings?.certificate; - - if (signingOptions?.algorithm !== undefined) { - logger?.info('Generating self-signed certificate with attributes'); - - const certificateAttributes: Array = Object.entries( - signingOptions.attributes, - ).map(([name, value]) => ({ name, value })); - - // TODO: Create a type def for selfsigned. - const signatures = require('selfsigned').generate(certificateAttributes, { - algorithm: signingOptions?.algorithm, - keySize: signingOptions?.size || 2048, - days: signingOptions?.days || 30, - }); - - logger?.info('Bootstrapping self-signed certificate'); - - credentials.key = signatures.private; - credentials.cert = signatures.cert; + if ('hostname' in httpsSettings?.certificate) { + credentials = await getGeneratedCertificate( + httpsSettings.certificate.hostname, + logger, + ); } else { - logger?.info('Bootstrapping cert from config'); + logger?.info('Loading certificate from config'); - credentials.key = signingOptions?.key; - credentials.cert = signingOptions?.cert; + credentials = { + key: httpsSettings?.certificate?.key, + cert: httpsSettings?.certificate?.cert, + }; } - if (credentials.key === '' || credentials.cert === '') { - throw new Error('Invalid credentials'); + if (!credentials.key || !credentials.cert) { + throw new Error('Invalid HTTPS credentials'); } return https.createServer(credentials, app) as http.Server; } + +async function getGeneratedCertificate(hostname: string, logger?: Logger) { + const hasModules = await fs.pathExists('node_modules'); + let certPath; + if (hasModules) { + certPath = resolvePath( + 'node_modules/.cache/backstage-backend/dev-cert.pem', + ); + await fs.ensureDir(dirname(certPath)); + } else { + certPath = resolvePath('.dev-cert.pem'); + } + + let cert = undefined; + if (await fs.pathExists(certPath)) { + const stat = await fs.stat(certPath); + const ageMs = Date.now() - stat.ctimeMs; + if (stat.isFile() && ageMs < ALMOST_MONTH_IN_MS) { + cert = await fs.readFile(certPath); + } + } + + if (cert) { + logger?.info('Using existing self-signed certificate'); + return { + key: cert, + cert: cert, + }; + } + + logger?.info('Generating new self-signed certificate'); + const newCert = await createCertificate(hostname); + await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); + return newCert; +} + +async function createCertificate(hostname: string) { + const attributes = [ + { + name: 'commonName', + value: 'dev-cert', + }, + ]; + + const sans = [ + { + type: 2, // DNS + value: 'localhost', + }, + { + type: 2, + value: 'localhost.localdomain', + }, + { + type: 2, + value: '[::1]', + }, + { + type: 7, // IP + ip: '127.0.0.1', + }, + { + type: 7, + ip: 'fe80::1', + }, + ]; + + // Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs + if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) { + sans.push( + IP_HOSTNAME_REGEX.test(hostname) + ? { + type: 7, + ip: hostname, + } + : { + type: 2, + value: hostname, + }, + ); + } + + const params = { + algorithm: 'sha256', + keySize: 2048, + days: 30, + extensions: [ + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true, + }, + { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + timeStamping: true, + }, + { + name: 'subjectAltName', + altNames: sans, + }, + ], + }; + + return new Promise<{ key: string; cert: string }>((resolve, reject) => + require('selfsigned').generate( + attributes, + params, + (err: Error, bundle: { private: string; cert: string }) => { + if (err) { + reject(err); + } else { + resolve({ key: bundle.private, cert: bundle.cert }); + } + }, + ), + ); +} diff --git a/packages/backend-common/src/service/lib/metrics.ts b/packages/backend-common/src/service/lib/metrics.ts deleted file mode 100644 index 37d441f53c..0000000000 --- a/packages/backend-common/src/service/lib/metrics.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import prom from 'prom-client'; -import promBundle from 'express-prom-bundle'; -import { RequestHandler } from 'express'; -import * as url from 'url'; - -const rootRegEx = new RegExp('^/([^/]*)/.*'); -const apiRegEx = new RegExp('^/api/([^/]*)/.*'); - -export function normalizePath(req: any): string { - const path = url.parse(req.originalUrl || req.url).pathname || '/'; - - // Capture /api/ and the plugin name - if (apiRegEx.test(path)) { - return path.replace(apiRegEx, '/api/$1'); - } - - // Only the first path segment at root level - return path.replace(rootRegEx, '/$1'); -} - -/** - * Adds a /metrics endpoint, register default runtime metrics and instrument the router. - */ -export function metricsHandler(): RequestHandler { - // We can only initialize the metrics once and have to clean them up between hot reloads - prom.register.clear(); - - return promBundle({ - includeMethod: true, - includePath: true, - // Using includePath alone is problematic, as it will include path labels with high - // cardinality (e.g. path params). Instead we would have to template them. However, this - // is difficult, as every backend plugin might use different routes. Instead we only take - // the first directory of the path, to have at least an idea how each plugin performs: - normalizePath, - promClient: { collectDefaultMetrics: {} }, - }); -} diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index d47e9003a5..1d840d8761 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,177 @@ # 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 + +- 7cfcd58ee: use node 14 for backend Dockerfile +- Updated dependencies [19554f6d6] +- Updated dependencies [33a82a713] +- Updated dependencies [5de26b9a6] +- Updated dependencies [30d6c78fb] +- Updated dependencies [5084e5039] +- Updated dependencies [a8573e53b] +- Updated dependencies [aed8f7f12] + - @backstage/plugin-scaffolder-backend@0.3.6 + - @backstage/plugin-catalog-backend@0.5.1 + - @backstage/plugin-techdocs-backend@0.5.0 + - example-app@0.2.8 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [c6eeefa35] +- Updated dependencies [fb386b760] +- Updated dependencies [c911061b7] +- Updated dependencies [7c3ffc0cd] +- Updated dependencies [dae4f3983] +- Updated dependencies [7b15cc271] +- Updated dependencies [e7496dc3e] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [8dd0a906d] +- Updated dependencies [4eafdec4a] +- Updated dependencies [6b37c95bf] +- Updated dependencies [8c31c681c] +- Updated dependencies [7b98e7fee] +- Updated dependencies [ac3560b42] +- Updated dependencies [94c65a9d4] +- Updated dependencies [0097057ed] + - @backstage/plugin-catalog-backend@0.5.0 + - @backstage/catalog-model@0.6.0 + - @backstage/plugin-techdocs-backend@0.4.0 + - @backstage/plugin-auth-backend@0.2.7 + - @backstage/backend-common@0.4.1 + - @backstage/plugin-scaffolder-backend@0.3.5 + - example-app@0.2.7 + - @backstage/plugin-kubernetes-backend@0.2.3 + +## 0.2.6 + +### Patch Changes + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions +- Updated dependencies [6e8bb3ac0] +- Updated dependencies [e708679d7] +- Updated dependencies [047c018c9] +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [38d63fbe1] +- Updated dependencies [1e22f8e0b] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/plugin-catalog-backend@0.4.0 + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/plugin-scaffolder-backend@0.3.4 + - @backstage/plugin-techdocs-backend@0.3.2 + - @backstage/catalog-model@0.5.0 + - example-app@0.2.6 + - @backstage/plugin-app-backend@0.3.3 + - @backstage/plugin-auth-backend@0.2.6 + - @backstage/plugin-graphql-backend@0.1.4 + - @backstage/plugin-kubernetes-backend@0.2.2 + - @backstage/plugin-proxy-backend@0.2.3 + - @backstage/plugin-rollbar-backend@0.1.5 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [ae95c7ff3] +- Updated dependencies [b4488ddb0] +- Updated dependencies [612368274] +- Updated dependencies [6a6c7c14e] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [e42402b47] +- Updated dependencies [bcc211a08] +- Updated dependencies [3619ea4c4] + - @backstage/plugin-techdocs-backend@0.3.1 + - @backstage/plugin-catalog-backend@0.3.0 + - @backstage/backend-common@0.3.3 + - @backstage/plugin-proxy-backend@0.2.2 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-kubernetes-backend@0.2.1 + - @backstage/plugin-app-backend@0.3.2 + - example-app@0.2.5 + - @backstage/plugin-auth-backend@0.2.5 + - @backstage/plugin-scaffolder-backend@0.3.3 + ## 0.2.4 ### Patch Changes diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 1aa5a355cf..f1bc764fd0 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12-buster +FROM node:14-buster WORKDIR /usr/src/app diff --git a/packages/backend/package.json b/packages/backend/package.json index beb2567bd7..269089a400 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,13 +1,22 @@ { "name": "example-backend", - "version": "0.2.4", + "version": "0.2.12", "main": "dist/index.cjs.js", "types": "src/index.ts", - "private": true, "license": "Apache-2.0", + "private": true, "engines": { "node": "12 || 14" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli backend:build", "build-image": "backstage-cli backend:build-image --build --tag example-backend", @@ -18,24 +27,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.3.2", - "@backstage/catalog-model": "^0.3.1", - "@backstage/config": "^0.1.1", - "@backstage/plugin-app-backend": "^0.3.1", - "@backstage/plugin-auth-backend": "^0.2.4", - "@backstage/plugin-catalog-backend": "^0.2.3", - "@backstage/plugin-graphql-backend": "^0.1.3", - "@backstage/plugin-kubernetes-backend": "^0.2.0", - "@backstage/plugin-proxy-backend": "^0.2.1", - "@backstage/plugin-rollbar-backend": "^0.1.4", - "@backstage/plugin-scaffolder-backend": "^0.3.2", - "@backstage/plugin-sentry-backend": "^0.1.3", - "@backstage/plugin-techdocs-backend": "^0.3.0", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", + "@backstage/backend-common": "^0.5.0", + "@backstage/catalog-model": "^0.7.0", + "@backstage/config": "^0.1.2", + "@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.0", - "example-app": "^0.2.4", + "dockerode": "^3.2.1", + "example-app": "^0.2.12", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -45,10 +54,12 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@types/dockerode": "^2.5.32", + "@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 b74954ecc5..81d0bb96d2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -25,22 +25,22 @@ import Router from 'express-promise-router'; import { createServiceBuilder, - loadBackendConfig, getRootLogger, - useHotMemoize, + loadBackendConfig, notFoundHandler, SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, + useHotMemoize, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import kubernetes from './plugins/kubernetes'; +import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; -import sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; @@ -76,9 +76,9 @@ async function main() { const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); - const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); @@ -86,10 +86,10 @@ async function main() { apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); - apiRouter.use('/sentry', await sentry(sentryEnv)); apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/kafka', await kafka(kafkaEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); apiRouter.use(notFoundHandler()); diff --git a/packages/backend/src/plugins/sentry.ts b/packages/backend/src/plugins/kafka.ts similarity index 70% rename from packages/backend/src/plugins/sentry.ts rename to packages/backend/src/plugins/kafka.ts index 5cd0e55761..e65ce6719c 100644 --- a/packages/backend/src/plugins/sentry.ts +++ b/packages/backend/src/plugins/kafka.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-sentry-backend'; -import type { PluginEnvironment } from '../types'; +import { createRouter } from '@backstage/plugin-kafka-backend'; +import { PluginEnvironment } from '../types'; -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter(logger); +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/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/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index de48280e64..afe92cc347 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -13,16 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { createRouter, - DirectoryPreparer, Preparers, Generators, - LocalPublish, - TechdocsGenerator, - CommonGitPreparer, - UrlPreparer, + Publisher, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -33,25 +28,26 @@ export default async function createPlugin({ discovery, reader, }: PluginEnvironment) { - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); - generators.register('techdocs', techdocsGenerator); + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); - const preparers = new Preparers(); + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); - const directoryPreparer = new DirectoryPreparer(logger); - preparers.register('dir', directoryPreparer); - - const commonGitPreparer = new CommonGitPreparer(logger); - preparers.register('github', commonGitPreparer); - preparers.register('gitlab', commonGitPreparer); - preparers.register('azure/api', commonGitPreparer); - - const urlPreparer = new UrlPreparer(reader, logger); - preparers.register('url', urlPreparer); - - const publisher = new LocalPublish(logger, discovery); + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 3454a6c8e0..c8042b72e9 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,41 @@ # @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 + +- Updated dependencies [c911061b7] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/catalog-model@0.4.0 + ## 0.3.1 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 4509b7d547..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.1", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/catalog-client" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.3.0", - "@backstage/config": "^0.1.1", + "@backstage/catalog-model": "^0.7.0", + "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.3.1", + "@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 630664e491..6a081c3336 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,104 @@ # @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 + +- ac3560b42: Remove `implementsApis` from `Component` entities. Deprecation happened in [#3449](https://github.com/backstage/backstage/pull/3449). + Use `providesApis` instead. + +### Patch Changes + +- c911061b7: Introduce a `profile` section for group entities that can optional include a + `displayName`, `email` and `picture`. +- 0e6298f7e: Ignore relations when comparing entities. This stops the refresh loop from rewriting entities over and over. + +## 0.5.0 + +### Minor Changes + +- 83b6e0c1f: Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity. + + See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details. + +### Patch Changes + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + +## 0.4.0 + +### Minor Changes + +- bcc211a08: k8s-plugin: refactor approach to use annotation based label-selector + +### Patch Changes + +- 08835a61d: Add support for relative targets and implicit types in Location entities. +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + ## 0.3.1 ### Patch Changes diff --git a/packages/catalog-model/examples/README.md b/packages/catalog-model/examples/README.md new file mode 100644 index 0000000000..77e1084899 --- /dev/null +++ b/packages/catalog-model/examples/README.md @@ -0,0 +1,4 @@ +# Example Entities + +This is a set of example entities that you can make use of to demonstrate basic +Backstage features. diff --git a/packages/catalog-model/examples/acme-corp.yaml b/packages/catalog-model/examples/acme-corp.yaml index e8047fc143..6449d548e0 100644 --- a/packages/catalog-model/examples/acme-corp.yaml +++ b/packages/catalog-model/examples/acme-corp.yaml @@ -4,6 +4,5 @@ metadata: name: acme-corp description: A collection of all Backstage example Groups spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml + - ./acme/org.yaml diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml index f992d155bc..f83587532c 100644 --- a/packages/catalog-model/examples/acme/backstage-group.yaml +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -5,7 +5,9 @@ metadata: description: The backstage sub-department spec: type: sub-department + profile: + displayName: Backstage + email: backstage@example.com + picture: https://avatars.dicebear.com/api/identicon/backstage@example.com.svg?background=%23fff&margin=25 parent: infrastructure - ancestors: [infrastructure, acme-corp] children: [team-a, team-b] - descendants: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/boxoffice-group.yaml b/packages/catalog-model/examples/acme/boxoffice-group.yaml index 0be1fe58cb..fbd454c56e 100644 --- a/packages/catalog-model/examples/acme/boxoffice-group.yaml +++ b/packages/catalog-model/examples/acme/boxoffice-group.yaml @@ -5,7 +5,9 @@ metadata: description: The boxoffice sub-department spec: type: sub-department + profile: + displayName: Box Office + email: boxoffice@example.com + # Intentional no picture for testing parent: infrastructure - ancestors: [infrastructure, acme-corp] children: [team-c, team-d] - descendants: [team-c, team-d] diff --git a/packages/catalog-model/examples/acme/infrastructure-group.yaml b/packages/catalog-model/examples/acme/infrastructure-group.yaml index 2341782944..584578e6ce 100644 --- a/packages/catalog-model/examples/acme/infrastructure-group.yaml +++ b/packages/catalog-model/examples/acme/infrastructure-group.yaml @@ -5,7 +5,6 @@ metadata: description: The infra department spec: type: department + # Intentional no profile for testing parent: acme-corp - ancestors: [acme-corp] children: [backstage, boxoffice] - descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 8c562aaf89..05afc265c0 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -5,10 +5,11 @@ metadata: description: The acme-corp organization spec: type: organization - ancestors: [] + profile: + displayName: ACME Corp + email: info@example.com + picture: https://avatars.dicebear.com/api/identicon/info@example.com.svg?background=%23fff&margin=25 children: [infrastructure] - descendants: - [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d] --- apiVersion: backstage.io/v1alpha1 kind: Location @@ -16,12 +17,11 @@ metadata: name: example-groups description: A collection of all Backstage example Groups spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/infrastructure-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/boxoffice-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/backstage-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-a-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-b-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-c-group.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-d-group.yaml + - ./infrastructure-group.yaml + - ./boxoffice-group.yaml + - ./backstage-group.yaml + - ./team-a-group.yaml + - ./team-b-group.yaml + - ./team-c-group.yaml + - ./team-d-group.yaml diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml index dacc4c6c3c..e343209d5f 100644 --- a/packages/catalog-model/examples/acme/team-a-group.yaml +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team A spec: type: team + profile: + # Intentional no displayName for testing + email: team-a@example.com + picture: https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25 parent: backstage - ancestors: [backstage, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -16,9 +18,9 @@ metadata: name: breanna.davison spec: profile: - displayName: Breanna Davison + # Intentional no displayName for testing email: breanna-davison@example.com - picture: https://example.com/staff/breanna.jpeg + picture: https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -29,7 +31,7 @@ spec: profile: displayName: Janelle Dawe email: janelle-dawe@example.com - picture: https://example.com/staff/janelle.jpeg + picture: https://avatars.dicebear.com/api/avataaars/janelle-dawe@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -40,5 +42,19 @@ spec: profile: displayName: Nigel Manning email: nigel-manning@example.com - picture: https://example.com/staff/nigel.jpeg + picture: https://avatars.dicebear.com/api/avataaars/nigel-manning@example.com.svg?background=%23fff + memberOf: [team-a] +--- +# This user is added as an example, to make it more easy for the "Guest" +# sign-in option to demonstrate some entities being owned. In a regular org, +# a guest user would probably not be registered like this. +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: guest +spec: + profile: + displayName: Guest User + email: guest@example.com + picture: https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff memberOf: [team-a] diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml index 00e9e41d80..20ab8721ea 100644 --- a/packages/catalog-model/examples/acme/team-b-group.yaml +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team B spec: type: team + profile: + displayName: Team B + email: team-b@example.com + picture: https://avatars.dicebear.com/api/identicon/team-b@example.com.svg?background=%23fff&margin=25 parent: backstage - ancestors: [backstage, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,7 +20,7 @@ spec: profile: displayName: Amelia Park email: amelia-park@example.com - picture: https://example.com/staff/amelia.jpeg + picture: https://avatars.dicebear.com/api/avataaars/amelia-park@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -29,7 +31,7 @@ spec: profile: displayName: Colette Brock email: colette-brock@example.com - picture: https://example.com/staff/colette.jpeg + picture: https://avatars.dicebear.com/api/avataaars/colette-brock@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -40,7 +42,7 @@ spec: profile: displayName: Jenny Doe email: jenny-doe@example.com - picture: https://example.com/staff/jenny.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jenny-doe@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -51,7 +53,7 @@ spec: profile: displayName: Jonathon Page email: jonathon-page@example.com - picture: https://example.com/staff/jonathon.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jonathon-page@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -62,5 +64,5 @@ spec: profile: displayName: Justine Barrow email: justine-barrow@example.com - picture: https://example.com/staff/justine.jpeg + picture: https://avatars.dicebear.com/api/avataaars/justine-barrow@example.com.svg?background=%23fff memberOf: [team-b] diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml index 0f97f69cb3..639b591d61 100644 --- a/packages/catalog-model/examples/acme/team-c-group.yaml +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team C spec: type: team + profile: + displayName: Team C + email: team-c@example.com + picture: https://avatars.dicebear.com/api/identicon/team-c@example.com.svg?background=%23fff&margin=25 parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,7 +20,7 @@ spec: profile: displayName: Calum Leavy email: calum-leavy@example.com - picture: https://example.com/staff/calum.jpeg + picture: https://avatars.dicebear.com/api/avataaars/calum-leavy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -29,7 +31,7 @@ spec: profile: displayName: Frank Tiernan email: frank-tiernan@example.com - picture: https://example.com/staff/frank.jpeg + picture: https://avatars.dicebear.com/api/avataaars/frank-tiernan@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -40,7 +42,7 @@ spec: profile: displayName: Peadar MacMahon email: peadar-macmahon@example.com - picture: https://example.com/staff/peadar.jpeg + picture: https://avatars.dicebear.com/api/avataaars/peadar-macmahon@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -51,7 +53,7 @@ spec: profile: displayName: Sarah Gilroy email: sarah-gilroy@example.com - picture: https://example.com/staff/sarah.jpeg + picture: https://avatars.dicebear.com/api/avataaars/sarah-gilroy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -62,5 +64,5 @@ spec: profile: displayName: Tara MacGovern email: tara-macgovern@example.com - picture: https://example.com/staff/tara.jpeg + picture: https://avatars.dicebear.com/api/avataaars/tara-macgovern@example.com.svg?background=%23fff memberOf: [team-c] diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml index 5a1a53a2e6..898ed8be6c 100644 --- a/packages/catalog-model/examples/acme/team-d-group.yaml +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -5,10 +5,12 @@ metadata: description: Team D spec: type: team + profile: + displayName: Team D + email: team-d@example.com + picture: https://avatars.dicebear.com/api/identicon/team-d@example.com.svg?background=%23fff&margin=25 parent: boxoffice - ancestors: [boxoffice, infrastructure, acme-corp] children: [] - descendants: [] --- apiVersion: backstage.io/v1alpha1 kind: User @@ -18,7 +20,7 @@ spec: profile: displayName: Eva MacDowell email: eva-macdowell@example.com - picture: https://example.com/staff/eva.jpeg + picture: https://avatars.dicebear.com/api/avataaars/eva-macdowell@example.com.svg?background=%23fff memberOf: [team-d] --- apiVersion: backstage.io/v1alpha1 @@ -29,5 +31,5 @@ spec: profile: displayName: Lucy Sheehan email: lucy-sheehan@example.com - picture: https://example.com/staff/lucy.jpeg + picture: https://avatars.dicebear.com/api/avataaars/lucy-sheehan@example.com.svg?background=%23fff memberOf: [team-d] diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 0278fc3078..e23f1b3656 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -4,10 +4,11 @@ metadata: name: example-apis description: A collection of all Backstage example APIs spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/petstore-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml + - ./apis/hello-world-api.yaml + - ./apis/petstore-api.yaml + - ./apis/spotify-api.yaml + - ./apis/streetlights-api.yaml + - ./apis/swapi-graphql.yaml + - ./apis/wayback-archive-api.yaml + - ./apis/wayback-search-api.yaml diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index 06c44b59d7..9471b810a1 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -4,14 +4,17 @@ metadata: name: example-components description: A collection of all Backstage example components spec: - type: github targets: - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/petstore-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml - - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml + - ./components/artist-lookup-component.yaml + - ./components/petstore-component.yaml + - ./components/playback-order-component.yaml + - ./components/podcast-api-component.yaml + - ./components/queue-proxy-component.yaml + - ./components/searcher-component.yaml + - ./components/playback-lib-component.yaml + - ./components/www-artist-component.yaml + - ./components/shuffle-api-component.yaml + - ./components/wayback-archive-component.yaml + - ./components/wayback-archive-ingestion-component.yaml + - ./components/wayback-archive-storage-component.yaml + - ./components/wayback-search-component.yaml diff --git a/packages/catalog-model/examples/all-domains.yaml b/packages/catalog-model/examples/all-domains.yaml new file mode 100644 index 0000000000..91a8a5b76d --- /dev/null +++ b/packages/catalog-model/examples/all-domains.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-domains + description: A collection of all Backstage example domains +spec: + targets: + - ./domains/artists-domain.yaml + - ./domains/playback-domain.yaml diff --git a/packages/catalog-model/examples/all-resources.yaml b/packages/catalog-model/examples/all-resources.yaml new file mode 100644 index 0000000000..d0986e3fe2 --- /dev/null +++ b/packages/catalog-model/examples/all-resources.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-resources + description: A collection of all Backstage example resources +spec: + targets: + - ./resources/artists-db-resource.yaml diff --git a/packages/catalog-model/examples/all-systems.yaml b/packages/catalog-model/examples/all-systems.yaml new file mode 100644 index 0000000000..165bee54e5 --- /dev/null +++ b/packages/catalog-model/examples/all-systems.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-systems + description: A collection of all Backstage example systems +spec: + targets: + - ./systems/artist-engagement-portal-system.yaml + - ./systems/audio-playback-system.yaml + - ./systems/podcast-system.yaml diff --git a/packages/catalog-model/examples/apis/hello-world-api.yaml b/packages/catalog-model/examples/apis/hello-world-api.yaml index 659c48adfb..0f9786329f 100644 --- a/packages/catalog-model/examples/apis/hello-world-api.yaml +++ b/packages/catalog-model/examples/apis/hello-world-api.yaml @@ -6,7 +6,7 @@ metadata: spec: type: grpc lifecycle: deprecated - owner: grpc@example.com + owner: team-c definition: | // Copyright 2015 gRPC authors. // diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml index c8ce576cce..a314b4e255 100644 --- a/packages/catalog-model/examples/apis/petstore-api.yaml +++ b/packages/catalog-model/examples/apis/petstore-api.yaml @@ -9,7 +9,7 @@ metadata: spec: type: openapi lifecycle: experimental - owner: pets@example.com + owner: team-c definition: | openapi: "3.0.0" info: diff --git a/packages/catalog-model/examples/apis/spotify-api.yaml b/packages/catalog-model/examples/apis/spotify-api.yaml index 14deb17abe..0b8e80b86e 100644 --- a/packages/catalog-model/examples/apis/spotify-api.yaml +++ b/packages/catalog-model/examples/apis/spotify-api.yaml @@ -12,6 +12,6 @@ metadata: spec: type: openapi lifecycle: production - owner: spotify@example.com + owner: team-a definition: $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml diff --git a/packages/catalog-model/examples/apis/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml index d53b05fc2d..725811d0cc 100644 --- a/packages/catalog-model/examples/apis/streetlights-api.yaml +++ b/packages/catalog-model/examples/apis/streetlights-api.yaml @@ -8,7 +8,7 @@ metadata: spec: type: asyncapi lifecycle: production - owner: streetlights@example.com + owner: team-c definition: | asyncapi: 2.0.0 info: diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml index bc5e8e60ac..0c1f7af5a4 100644 --- a/packages/catalog-model/examples/apis/swapi-graphql.yaml +++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml @@ -6,7 +6,7 @@ metadata: spec: type: graphql lifecycle: production - owner: yoda@dagobah.space + owner: team-b definition: | schema { query: Root diff --git a/packages/catalog-model/examples/apis/wayback-archive-api.yaml b/packages/catalog-model/examples/apis/wayback-archive-api.yaml new file mode 100644 index 0000000000..dbed93dbe8 --- /dev/null +++ b/packages/catalog-model/examples/apis/wayback-archive-api.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: wayback-archive + description: Archive API for the wayback machine +spec: + type: openapi + lifecycle: production + owner: team-a + definition: + $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/wayback/1.0.0/openapi.yaml diff --git a/packages/catalog-model/examples/apis/wayback-search-api.yaml b/packages/catalog-model/examples/apis/wayback-search-api.yaml new file mode 100644 index 0000000000..b39b5df468 --- /dev/null +++ b/packages/catalog-model/examples/apis/wayback-search-api.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: wayback-search + description: Search API for the wayback machine +spec: + type: openapi + lifecycle: production + owner: team-a + definition: + $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/archive.org/search/1.0.0/openapi.yaml diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index 79dd3bccf6..3fc516ece9 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -9,4 +9,5 @@ metadata: spec: type: service lifecycle: experimental - owner: artists@example.com + owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/components/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml index 7e2093ad09..acbb2f82b0 100644 --- a/packages/catalog-model/examples/components/petstore-component.yaml +++ b/packages/catalog-model/examples/components/petstore-component.yaml @@ -6,8 +6,8 @@ metadata: spec: type: service lifecycle: experimental - owner: pets@example.com - implementsApis: + owner: team-c + providesApis: - petstore - streetlights - hello-world diff --git a/packages/catalog-model/examples/components/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml index c32332d4d2..de7e93d38d 100644 --- a/packages/catalog-model/examples/components/playback-lib-component.yaml +++ b/packages/catalog-model/examples/components/playback-lib-component.yaml @@ -6,4 +6,5 @@ metadata: spec: type: library lifecycle: experimental - owner: players@example.com + 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 3e46953928..9146063886 100644 --- a/packages/catalog-model/examples/components/playback-order-component.yaml +++ b/packages/catalog-model/examples/components/playback-order-component.yaml @@ -9,4 +9,5 @@ metadata: spec: type: service lifecycle: production - owner: guest + 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 c1b1c9281c..30d254a00f 100644 --- a/packages/catalog-model/examples/components/podcast-api-component.yaml +++ b/packages/catalog-model/examples/components/podcast-api-component.yaml @@ -8,4 +8,5 @@ metadata: spec: type: service lifecycle: experimental - owner: players@example.com + 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 c9b130db52..a2d5ae5ea4 100644 --- a/packages/catalog-model/examples/components/queue-proxy-component.yaml +++ b/packages/catalog-model/examples/components/queue-proxy-component.yaml @@ -9,4 +9,5 @@ metadata: spec: type: website lifecycle: production - owner: tools@example.com + owner: team-b + system: podcast diff --git a/packages/catalog-model/examples/components/searcher-component.yaml b/packages/catalog-model/examples/components/searcher-component.yaml index 042fcb24a9..77150c96f0 100644 --- a/packages/catalog-model/examples/components/searcher-component.yaml +++ b/packages/catalog-model/examples/components/searcher-component.yaml @@ -8,4 +8,4 @@ metadata: spec: type: service lifecycle: production - owner: guest + owner: user:guest diff --git a/packages/catalog-model/examples/components/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml index 1f9de46d4e..6328ebdf3b 100644 --- a/packages/catalog-model/examples/components/shuffle-api-component.yaml +++ b/packages/catalog-model/examples/components/shuffle-api-component.yaml @@ -8,4 +8,5 @@ metadata: spec: type: service lifecycle: production - owner: guest + owner: user:guest + system: audio-playback diff --git a/packages/catalog-model/examples/components/wayback-archive-component.yaml b/packages/catalog-model/examples/components/wayback-archive-component.yaml new file mode 100644 index 0000000000..5d558db64a --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-component.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive + description: Archive of the wayback machine +spec: + type: service + lifecycle: production + owner: team-a + providesApis: + - wayback-archive diff --git a/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml new file mode 100644 index 0000000000..4f870ac831 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-ingestion + description: Ingestion subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-d + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml new file mode 100644 index 0000000000..78d67258d7 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-storage + description: Storage subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-a + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/wayback-search-component.yaml b/packages/catalog-model/examples/components/wayback-search-component.yaml new file mode 100644 index 0000000000..0187e4441f --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-search-component.yaml @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-search + description: Search of the wayback machine +spec: + type: service + lifecycle: production + owner: team-a + providesApis: + - wayback-search + consumesApis: + - wayback-archive diff --git a/packages/catalog-model/examples/components/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml index 84379c93fc..3acb6fc6a6 100644 --- a/packages/catalog-model/examples/components/www-artist-component.yaml +++ b/packages/catalog-model/examples/components/www-artist-component.yaml @@ -6,4 +6,5 @@ metadata: spec: type: website lifecycle: production - owner: artists@example.com + 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 53f56c6600..e03ce8c465 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.3.1", + "version": "0.7.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/catalog-model" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", + "@backstage/config": "^0.1.2", "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", "json-schema": "^0.2.5", @@ -29,7 +38,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.3.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 2a196a65d8..2c63cc4c49 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -46,14 +46,14 @@ export function generateEntityEtag(): string { * are added or existing annotations were changed (since they are effectively * merged when doing updates). * + * Note that this comparison does NOT take state, relations or similar into + * account. It only compares the actual input entity data, i.e. metadata and + * spec. + * * @param previous The old state of the entity * @param next The new state of the entity */ export function entityHasChanges(previous: Entity, next: Entity): boolean { - if (entityHasAnnotationChanges(previous, next)) { - return true; - } - const e1 = lodash.cloneDeep(previous); const e2 = lodash.cloneDeep(next); @@ -63,6 +63,18 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { if (!e2.metadata.labels) { e2.metadata.labels = {}; } + if (!e1.metadata.annotations) { + e1.metadata.annotations = {}; + } + if (!e2.metadata.annotations) { + e2.metadata.annotations = {}; + } + if (!e1.metadata.tags) { + e1.metadata.tags = []; + } + if (!e2.metadata.tags) { + e2.metadata.tags = []; + } // Remove generated fields delete e1.metadata.uid; @@ -72,9 +84,9 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { delete e2.metadata.etag; delete e2.metadata.generation; - // Remove already compared things - delete e1.metadata.annotations; - delete e2.metadata.annotations; + // Remove things that we explicitly do not compare + delete e1.relations; + delete e2.relations; return !lodash.isEqual(e1, e2); } @@ -98,14 +110,6 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { const result = lodash.cloneDeep(next); - // Annotations are merged, with the new ones taking precedence - if (previous.metadata.annotations) { - next.metadata.annotations = { - ...previous.metadata.annotations, - ...next.metadata.annotations, - }; - } - // Generated fields are copied and updated const bumpEtag = entityHasChanges(previous, result); const bumpGeneration = !lodash.isEqual(previous.spec, result.spec); @@ -115,26 +119,3 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { return result; } - -function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean { - // Since the next annotations get merged into the previous, extract only - // the overlapping keys and check if their values match. - if (next.metadata.annotations) { - if (!previous.metadata.annotations) { - return true; - } - if ( - !lodash.isEqual( - next.metadata.annotations, - lodash.pick( - previous.metadata.annotations, - Object.keys(next.metadata.annotations), - ), - ) - ) { - return true; - } - } - - return false; -} diff --git a/packages/catalog-model/src/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 8eec63daac..9284a5d5b1 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -33,9 +33,10 @@ describe('ComponentV1alpha1Validator', () => { type: 'service', lifecycle: 'production', owner: 'me', - implementsApis: ['api-0'], + subcomponentOf: 'monolith', providesApis: ['api-0'], consumesApis: ['api-0'], + system: 'system', }, }; }); @@ -104,24 +105,19 @@ describe('ComponentV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/owner/); }); - it('accepts missing implementsApis', async () => { - delete (entity as any).spec.implementsApis; + it('accepts missing subcomponentOf', async () => { + delete (entity as any).spec.subcomponentOf; await expect(validator.check(entity)).resolves.toBe(true); }); - it('rejects empty implementsApis', async () => { - (entity as any).spec.implementsApis = ['']; - await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); + it('rejects wrong subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = 7; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); }); - it('rejects undefined implementsApis', async () => { - (entity as any).spec.implementsApis = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); - }); - - it('accepts no implementsApis', async () => { - (entity as any).spec.implementsApis = []; - await expect(validator.check(entity)).resolves.toBe(true); + it('rejects empty subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = ''; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); }); it('accepts missing providesApis', async () => { @@ -163,4 +159,19 @@ describe('ComponentV1alpha1Validator', () => { (entity as any).spec.consumesApis = []; await expect(validator.check(entity)).resolves.toBe(true); }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 5fd4f88537..c55c48055a 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -29,18 +29,10 @@ const schema = yup.object>({ type: yup.string().required().min(1), lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), - implementsApis: yup.array(yup.string().required()).notRequired(), + subcomponentOf: yup.string().notRequired().min(1), providesApis: yup.array(yup.string().required()).notRequired(), consumesApis: yup.array(yup.string().required()).notRequired(), - kubernetes: yup - .object({ - selector: yup - .object({ - matchLabels: yup.object().required(), - }) - .required(), - }) - .notRequired(), + system: yup.string().notRequired().min(1), }) .required(), }); @@ -52,21 +44,10 @@ export interface ComponentEntityV1alpha1 extends Entity { type: string; lifecycle: string; owner: string; - /** - * @deprecated This field will disappear on Dec 14th, 2020. Please remove - * any consuming code. The new field providesApis provides the - * same functionality like before. - */ - implementsApis?: string[]; + subcomponentOf?: string; providesApis?: string[]; consumesApis?: string[]; - kubernetes?: { - selector: { - matchLabels: { - [key: string]: 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/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts index 8b7bef8ff7..b25795cd31 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -28,15 +28,17 @@ describe('GroupV1alpha1Validator', () => { kind: 'Group', metadata: { name: 'doe-squad', - title: 'Doe Squad', description: 'A squad for John and Jane', }, spec: { type: 'squad', + profile: { + displayName: 'Doe Squad', + email: 'doe@doe.org', + picture: 'https://doe.org/doe', + }, parent: 'group-a', - ancestors: ['group-a', 'global-synergies', 'acme-corp'], children: ['child-a', 'child-b'], - descendants: ['desc-a', 'desc-b'], }, }; }); @@ -75,6 +77,70 @@ describe('GroupV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/type/); }); + // profile + + it('accepts missing profile', async () => { + delete (entity as any).spec.profile; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong profile', async () => { + (entity as any).spec.profile = 7; + await expect(validator.check(entity)).rejects.toThrow(/profile/); + }); + + it('profile accepts missing displayName', async () => { + delete (entity as any).spec.profile.displayName; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong displayName', async () => { + (entity as any).spec.profile.displayName = 7; + await expect(validator.check(entity)).rejects.toThrow(/displayName/); + }); + + it('profile rejects empty displayName', async () => { + (entity as any).spec.profile.displayName = ''; + await expect(validator.check(entity)).rejects.toThrow(/displayName/); + }); + + it('profile accepts missing email', async () => { + delete (entity as any).spec.profile.email; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong email', async () => { + (entity as any).spec.profile.email = 7; + await expect(validator.check(entity)).rejects.toThrow(/email/); + }); + + it('profile rejects empty email', async () => { + (entity as any).spec.profile.email = ''; + await expect(validator.check(entity)).rejects.toThrow(/email/); + }); + + it('profile accepts missing picture', async () => { + delete (entity as any).spec.profile.picture; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('profile rejects wrong picture', async () => { + (entity as any).spec.profile.picture = 7; + await expect(validator.check(entity)).rejects.toThrow(/picture/); + }); + + it('profile rejects empty picture', async () => { + (entity as any).spec.profile.picture = ''; + await expect(validator.check(entity)).rejects.toThrow(/picture/); + }); + + it('profile accepts unknown additional fields', async () => { + (entity as any).spec.profile.foo = 'data'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + // parent + it('accepts missing parent', async () => { delete (entity as any).spec.parent; await expect(validator.check(entity)).resolves.toBe(true); @@ -85,25 +151,7 @@ describe('GroupV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/parent/); }); - it('rejects missing ancestors', async () => { - delete (entity as any).spec.ancestors; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('rejects empty ancestors', async () => { - (entity as any).spec.ancestors = ['']; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('rejects undefined ancestors', async () => { - (entity as any).spec.ancestors = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/ancestor/); - }); - - it('accepts no ancestors', async () => { - (entity as any).spec.ancestors = []; - await expect(validator.check(entity)).resolves.toBe(true); - }); + // children it('rejects missing children', async () => { delete (entity as any).spec.children; @@ -124,24 +172,4 @@ describe('GroupV1alpha1Validator', () => { (entity as any).spec.children = []; await expect(validator.check(entity)).resolves.toBe(true); }); - - it('rejects missing descendants', async () => { - delete (entity as any).spec.descendants; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('rejects empty descendants', async () => { - (entity as any).spec.descendants = ['']; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('rejects undefined descendants', async () => { - (entity as any).spec.descendants = [undefined]; - await expect(validator.check(entity)).rejects.toThrow(/descendants/); - }); - - it('accepts no descendants', async () => { - (entity as any).spec.descendants = []; - await expect(validator.check(entity)).resolves.toBe(true); - }); }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 7be432ad1f..6f2664af77 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -27,26 +27,23 @@ const schema = yup.object>({ spec: yup .object({ type: yup.string().required().min(1), + profile: yup + .object({ + displayName: yup.string().min(1).notRequired(), + email: yup.string().min(1).notRequired(), + picture: yup.string().min(1).notRequired(), + }) + .notRequired(), parent: yup.string().notRequired().min(1), // Use these manual tests because yup .required() requires at least // one element and there is no simple workaround -_- // the cast is there to convince typescript that the array itself is // required without using .required() - ancestors: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'ancestors must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, children: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'children must be defined', test: v => Boolean(v), }) as yup.ArraySchema, - descendants: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'descendants must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, }) .required(), }); @@ -56,24 +53,13 @@ export interface GroupEntityV1alpha1 extends Entity { kind: typeof KIND; spec: { type: string; + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; parent?: string; - /** - * @deprecated This field will disappear on Dec 6th, 2020. Please remove - * any consuming code. Producers can stop producing this field - * before that date, as long as the catalog backend uses the - * BuiltinKindsEntityProcessor which inserts the fields in the - * mean time. - */ - ancestors: string[]; children: string[]; - /** - * @deprecated This field will disappear on Dec 6th, 2020. Please remove - * any consuming code. Producers can stop producing this field - * before that date, as long as the catalog backend uses the - * BuiltinKindsEntityProcessor which inserts the fields in the - * mean time. - */ - descendants: string[]; }; } diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts index d9c1e9185f..2451df8e64 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -54,9 +54,9 @@ describe('LocationV1alpha1Validator', () => { await expect(validator.check(entity)).resolves.toBe(false); }); - it('rejects missing type', async () => { + it('accepts missing type', async () => { delete (entity as any).spec.type; - await expect(validator.check(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong type', async () => { diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index e536be7922..9cd767de94 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -26,7 +26,7 @@ const schema = yup.object>({ kind: yup.string().required().equals([KIND]), spec: yup .object({ - type: yup.string().required().min(1), + type: yup.string().notRequired().min(1), target: yup.string().notRequired().min(1), targets: yup.array(yup.string().required()).notRequired(), }) @@ -37,7 +37,7 @@ export interface LocationEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; spec: { - type: string; + type?: string; target?: string; targets?: string[]; }; 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 d1770eaf1f..7998d408e0 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,76 @@ # @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 + +- d45efbc9b: Fix typo in .app.listen.port config schema + +## 0.4.3 + +### Patch Changes + +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 7d72f9b09: Fix for `app.listen.host` configuration not properly overriding listening host. + +## 0.4.2 + +### Patch Changes + +- c36a01b4c: Re-enable symlink resolution during bundling, and switch to using a resolve plugin for external linked packages. + +## 0.4.1 + +### Patch Changes + +- 06dbe707b: Update experimental backend bundle command to only output archives to `dist/` instead of a full workspace mirror in `dist-workspace/`. +- 011708102: Fixes a big in the bundling logic that caused `node_modules` inside local monorepo packages to be transformed. +- 61897fb2c: Fix config schema for `.app.listen` +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + +## 0.4.0 + +### Minor Changes + +- 00670a96e: sort product panels and navigation menu by greatest cost + update tsconfig.json to use ES2020 api + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError +- 4a655c89d: Bump versions of `esbuild` and `rollup-plugin-esbuild` +- 8a16e8af8: Support `.npmrc` when building with private NPM registries +- Updated dependencies [4e7091759] +- Updated dependencies [b4488ddb0] + - @backstage/config-loader@0.4.0 + ## 0.3.2 ### Patch Changes diff --git a/packages/cli/README.md b/packages/cli/README.md index 9eac5cdfe4..cd6ad8094e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,7 +4,7 @@ This package provides a CLI for developing Backstage plugins and apps. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/cli diff --git a/packages/cli/asset-types/asset-types.d.ts b/packages/cli/asset-types/asset-types.d.ts index 28c1fde9e3..9db4438fd4 100644 --- a/packages/cli/asset-types/asset-types.d.ts +++ b/packages/cli/asset-types/asset-types.d.ts @@ -97,3 +97,15 @@ declare module '*.module.sass' { const classes: { readonly [key: string]: string }; export default classes; } + +// NOTE(freben): Both the fix, and the placement of the fix, are not great. +// +// The fix is because the PositionError was renamed to +// GeolocationPositionError outside of our control, and react-use is dependent +// on the old name. +// +// The placement is because it's the one location we have at the moment, where +// a central .d.ts file is imported by the frontend and can be amended. +// +// After both TS and react-use are bumped high enough, this should be removed. +type PositionError = GeolocationPositionError; 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 eff546168e..a98534ecb6 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.3.2", + "version": "0.4.7", "private": false, "publishConfig": { "access": "public" @@ -29,11 +29,12 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", - "@backstage/config-loader": "^0.3.0", + "@backstage/config": "^0.1.2", + "@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", @@ -44,13 +45,13 @@ "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "5.4.x", "@svgr/plugin-svgo": "5.4.x", - "@svgr/rollup": "5.4.x", + "@svgr/rollup": "5.5.x", "@svgr/webpack": "5.4.x", "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^2.5.0", - "@typescript-eslint/eslint-plugin": "^v3.10.1", - "@typescript-eslint/parser": "^v3.10.1", + "@typescript-eslint/eslint-plugin": "^v4.14.0", + "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", "bfj": "^7.0.2", "chalk": "^4.0.0", @@ -66,9 +67,10 @@ "eslint-plugin-import": "^2.20.2", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsx-a11y": "^6.2.1", - "eslint-plugin-monorepo": "^0.2.1", + "eslint-plugin-monorepo": "^0.3.2", "eslint-plugin-react": "^7.12.4", "eslint-plugin-react-hooks": "^4.0.0", + "express": "^4.17.1", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", @@ -111,21 +113,21 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.3.2", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/dev-utils": "^0.1.4", - "@backstage/test-utils": "^0.1.3", - "@backstage/theme": "^0.2.1", + "@backstage/backend-common": "^0.5.0", + "@backstage/config": "^0.1.2", + "@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", "@types/inquirer": "^7.3.1", - "@types/mini-css-extract-plugin": "^0.9.1", + "@types/mini-css-extract-plugin": "^1.2.2", "@types/mock-fs": "^4.13.0", "@types/node": "^13.7.2", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "@types/rollup-plugin-peer-deps-external": "^2.2.0", @@ -178,11 +180,11 @@ "description": "Listening configuration for local development", "properties": { "host": { - "type": "number", + "type": "string", "visibility": "frontend", "description": "The host that the frontend should be bound to. Only used for local development." }, - "post": { + "port": { "type": "number", "visibility": "frontend", "description": "The port that the frontend should be bound to. Only used for local development." diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index b6ae0dd579..654f51cd10 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -34,11 +34,15 @@ export default async (cmd: Command) => { const pkgPath = paths.resolveTarget(PKG_PATH); const pkg = await fs.readJson(pkgPath); const appConfigs = await findAppConfigs(); + const npmrc = (await fs.pathExists(paths.resolveTargetRoot('.npmrc'))) + ? ['.npmrc'] + : []; const tempDistWorkspace = await createDistWorkspace([pkg.name], { buildDependencies: Boolean(cmd.build), files: [ 'package.json', 'yarn.lock', + ...npmrc, ...appConfigs, { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, ], diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts index e604ae9733..25322045e1 100644 --- a/packages/cli/src/commands/backend/bundle.ts +++ b/packages/cli/src/commands/backend/bundle.ts @@ -14,26 +14,59 @@ * limitations under the License. */ -import { Command } from 'commander'; +import os from 'os'; import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import tar, { CreateOptions } from 'tar'; +import { Command } from 'commander'; import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { buildPackage, Output } from '../../lib/builder'; -const PKG_PATH = 'package.json'; -const TARGET_DIR = 'dist-workspace'; +const BUNDLE_FILE = 'bundle.tar.gz'; +const SKELETON_FILE = 'skeleton.tar.gz'; export default async (cmd: Command) => { - const targetDir = paths.resolveTarget(TARGET_DIR); - const pkgPath = paths.resolveTarget(PKG_PATH); - const pkg = await fs.readJson(pkgPath); + const targetDir = paths.resolveTarget('dist'); + const pkg = await fs.readJson(paths.resolveTarget('package.json')); - await fs.remove(targetDir); - await fs.mkdir(targetDir); - await createDistWorkspace([pkg.name], { - targetDir: targetDir, - buildDependencies: Boolean(cmd.build), - parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), - skeleton: 'skeleton.tar', - }); + // We build the target package without generating type declarations. + await buildPackage({ outputs: new Set([Output.cjs]) }); + + const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle')); + try { + await createDistWorkspace([pkg.name], { + targetDir: tmpDir, + buildDependencies: Boolean(cmd.buildDependencies), + buildExcludes: [pkg.name], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + skeleton: SKELETON_FILE, + }); + + // We built the target backend package using the regular build process, but the result of + // that has now been packed into the dist workspace, so clean up the dist dir. + await fs.remove(targetDir); + await fs.mkdir(targetDir); + + // Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice. + await fs.move( + resolvePath(tmpDir, SKELETON_FILE), + resolvePath(targetDir, SKELETON_FILE), + ); + + // Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache. + await tar.create( + { + file: resolvePath(targetDir, BUNDLE_FILE), + cwd: tmpDir, + portable: true, + noMtime: true, + gzip: true, + } as CreateOptions & { noMtime: boolean }, + [''], + ); + } finally { + await fs.remove(tmpDir); + } }; diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 8be88adce7..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 2b0e3e25b9..1d267c6889 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -45,8 +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') + .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 @@ -55,7 +59,11 @@ export function registerCommands(program: CommanderStatic) { .helpOption(', --backstage-cli-help') // Let docker handle --help .option('--build', 'Build packages before packing them into the image') .description( - 'Bundles the package into a docker image. All extra args are forwarded to docker image build', + // TODO: Add example use cases in Backstage documentation. + // For example, if a $NPM_TOKEN needs to be exposed, run `backend:build-image --secret + // id=NPM_TOKEN,src=/NPM_TOKEN.txt`. + 'Bundles the package into a docker image. All extra args are forwarded to ' + + '`docker image build`.', ) .action(lazy(() => import('./backend/buildImage').then(m => m.default))); @@ -82,9 +90,9 @@ export function registerCommands(program: CommanderStatic) { 'Create plugin with the backend dependencies as default', ) .description('Creates a new plugin in the current repository') - .option('--scope ', 'NPM scope') - .option('--npm-registry ', 'NPM registry URL') - .option('--no-private', 'Public NPM Package') + .option('--scope ', 'npm scope') + .option('--npm-registry ', 'npm registry URL') + .option('--no-private', 'Public npm package') .action( lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); @@ -145,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( @@ -161,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', @@ -197,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 @@ -207,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 new file mode 100644 index 0000000000..f3906bfd0d --- /dev/null +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as os from 'os'; +import * as path from 'path'; +import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; + +describe('LinkedPackageResolvePlugin', () => { + const root = os.platform() === 'win32' ? 'C:\\root' : '/root'; + + it('should re-write paths for external packages', () => { + const plugin = new LinkedPackageResolvePlugin( + path.resolve(root, 'repo/node_modules'), + [ + { + name: 'a', + location: path.resolve(root, 'external-a'), + }, + { + name: '@s/b', + location: path.resolve(root, 'external-b'), + }, + ], + ); + + const tapAsync = jest.fn(); + const doResolve = jest.fn(); + + const resolver = { + hooks: { resolve: { tapAsync } }, + doResolve, + }; + plugin.apply(resolver); + + expect(tapAsync).toHaveBeenCalledTimes(1); + expect(tapAsync).toHaveBeenCalledWith( + 'LinkedPackageResolvePlugin', + expect.any(Function), + ); + expect(doResolve).toHaveBeenCalledTimes(0); + + // Internal module resolution is not affected + const tap = tapAsync.mock.calls[0][1]; + const callbackX = jest.fn(); + tap( + { + request: path.resolve(root, 'repo/package/x/src/module.ts'), + path: path.resolve(root, 'repo/package/x/src'), + context: { + issuer: path.resolve(root, 'repo/package/x/src/index.ts'), + }, + }, + 'some-context', + callbackX, + ); + expect(callbackX).toHaveBeenCalledTimes(1); + expect(callbackX).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + + // Path is sometimes false + const callbackFalse = jest.fn(); + tap( + { + request: 'dummy', + path: false, + }, + 'some-context', + callbackFalse, + ); + expect(callbackFalse).toHaveBeenCalledTimes(1); + expect(callbackFalse).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + + // Internal modules with a path prefix of an external module + const callbackY = jest.fn(); + tap( + { + request: path.resolve(root, 'external-aa/src/module.ts'), + path: path.resolve(root, 'external-aa/src'), + context: { + issuer: path.resolve(root, 'external-aa/src/index.ts'), + }, + }, + 'some-context', + callbackY, + ); + expect(callbackY).toHaveBeenCalledTimes(1); + expect(callbackY).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + + // External modules have their path and issuer context rewritten, but not the request + const callbackA = jest.fn(); + tap( + { + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'external-a/src'), + context: { + issuer: path.resolve(root, 'external-a/src/index.ts'), + }, + }, + 'some-context', + callbackA, + ); + expect(callbackA).toHaveBeenCalledTimes(0); + expect(doResolve).toHaveBeenCalledTimes(1); + expect(doResolve).toHaveBeenCalledWith( + resolver.hooks.resolve, + { + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/a/src'), + context: { + issuer: path.resolve(root, 'repo/node_modules/a/src/index.ts'), + }, + }, + `resolve ${path.resolve( + root, + 'external-a/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/a')}`, + 'some-context', + callbackA, + ); + + // Also handles scoped packages correctly, and issuer is not required + const callbackB = jest.fn(); + tap( + { + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'external-b/src'), + context: { + issuer: false, + }, + }, + 'some-context', + callbackB, + ); + expect(callbackB).toHaveBeenCalledTimes(0); + expect(doResolve).toHaveBeenCalledTimes(2); + expect(doResolve).toHaveBeenLastCalledWith( + resolver.hooks.resolve, + { + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/@s/b/src'), + context: { + issuer: false, + }, + }, + `resolve ${path.resolve( + root, + 'external-b/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/@s/b')}`, + 'some-context', + callbackB, + ); + + expect(tapAsync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts new file mode 100644 index 0000000000..0478337c61 --- /dev/null +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve as resolvePath } from 'path'; +import { ResolvePlugin } from 'webpack'; +import { isChildPath } from './paths'; +import { LernaPackage } from './types'; + +// Enables proper resolution of packages when linking in external packages. +// Without this the packages would depend on dependencies in the node_modules +// of the external packages themselves, leading to module duplication +export class LinkedPackageResolvePlugin implements ResolvePlugin { + constructor( + private readonly targetModules: string, + private readonly packages: LernaPackage[], + ) {} + + apply(resolver: any) { + resolver.hooks.resolve.tapAsync( + 'LinkedPackageResolvePlugin', + ( + data: { + request: string; + path?: false | string; + context?: { issuer?: string }; + }, + context: unknown, + callback: () => void, + ) => { + const pkg = this.packages.find( + pkg => data.path && isChildPath(pkg.location, data.path), + ); + if (!pkg) { + callback(); + return; + } + + // pkg here is an external package. We rewrite the context of any imports to resolve + // from the location of the package within the node_modules of the target root rather + // than the real location of the external package. + const modulesLocation = resolvePath(this.targetModules, pkg.name); + const newContext = data.context?.issuer + ? { + ...data.context, + issuer: data.context.issuer.replace( + pkg.location, + modulesLocation, + ), + } + : data.context; + + // Re-run resolution but this time from the point of view of our target monorepo rather + // than the location of the external package. By resolving modules using this method we avoid + // pulling in e.g. `react` from the external repo, which would otherwise lead to conflicts. + resolver.doResolve( + resolver.hooks.resolve, + { + ...data, + context: newContext, + path: data.path && data.path.replace(pkg.location, modulesLocation), + }, + `resolve ${data.request} in ${modulesLocation}`, + context, + callback, + ); + }, + ); + } +} diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index b5a27dd624..c53a8329f5 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -24,9 +24,10 @@ import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths } from './paths'; +import { BundlingPaths, isChildPath } from './paths'; import { transforms } from './transforms'; -import { BundlingOptions, BackendBundlingOptions } from './types'; +import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; +import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; import { version } from '../../lib/version'; import { paths as cliPaths } from '../../lib/paths'; import { runPlain } from '../run'; @@ -70,9 +71,7 @@ async function readBuildInfo() { }; } -async function loadLernaPackages(): Promise< - { name: string; location: string }[] -> { +async function loadLernaPackages(): Promise { const LernaProject = require('@lerna/project'); const project = new LernaProject(cliPaths.targetDir); return project.getPackages(); @@ -85,12 +84,12 @@ export async function createConfig( const { checksEnabled, isDev, frontendConfig } = options; const packages = await loadLernaPackages(); - const { plugins, loaders } = transforms({ - ...options, - externalTransforms: packages.map(({ name }) => - cliPaths.resolveTargetRoot('node_modules', name), - ), - }); + const { plugins, loaders } = transforms(options); + // Any package that is part of the monorepo but outside the monorepo root dir need + // separate resolution logic. + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); @@ -165,6 +164,7 @@ export async function createConfig( extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], mainFields: ['browser', 'module', 'main'], plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], [paths.targetPackageJson], @@ -173,10 +173,6 @@ export async function createConfig( alias: { 'react-dom': '@hot-loader/react-dom', }, - // Enables proper resolution of packages when linking in external packages. - // Without this the packages would depend on dependencies in the node_modules - // of the external packages themselves, leading to module duplication - symlinks: false, }, module: { rules: loaders, @@ -205,13 +201,11 @@ export async function createBackendConfig( const moduleDirs = packages.map((p: any) => resolvePath(p.location, 'node_modules'), ); + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); // See frontend config - const { loaders } = transforms({ - ...options, - externalTransforms: packages.map(({ name }) => - cliPaths.resolveTargetRoot('node_modules', name), - ), - }); + const { loaders } = transforms(options); return { mode: isDev ? 'development' : 'production', @@ -253,6 +247,7 @@ export async function createBackendConfig( mainFields: ['browser', 'module', 'main'], modules: [paths.rootNodeModules, ...moduleDirs], plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], [paths.targetPackageJson], @@ -261,7 +256,6 @@ export async function createBackendConfig( alias: { 'react-dom': '@hot-loader/react-dom', }, - symlinks: false, // See frontend config, added here for the same reason }, module: { rules: loaders, 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/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index a5cdd1300c..34284ead18 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -61,7 +61,7 @@ export async function serveBundle(options: ServeOptions) { }); await new Promise((resolve, reject) => { - server.listen(port, url.hostname, (err?: Error) => { + server.listen(port, host, (err?: Error) => { if (err) { reject(err); return; diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 6dc32e6563..4f097343c2 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -25,23 +25,17 @@ type Transforms = { type TransformOptions = { isDev: boolean; - // External paths that should be transformed - externalTransforms: string[]; }; export const transforms = (options: TransformOptions): Transforms => { - const { isDev, externalTransforms } = options; + const { isDev } = options; const extraTransforms = isDev ? ['react-hot-loader'] : []; - const transformExcludeCondition = { - and: [/node_modules/, { not: externalTransforms }], - }; - const loaders = [ { test: /\.(tsx?)$/, - exclude: transformExcludeCondition, + exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['typescript', 'jsx', ...extraTransforms], @@ -50,7 +44,7 @@ export const transforms = (options: TransformOptions): Transforms => { }, { test: /\.(jsx?|mjs)$/, - exclude: transformExcludeCondition, + exclude: /node_modules/, loader: require.resolve('@sucrase/webpack-loader'), options: { transforms: ['jsx', ...extraTransforms], diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 71343a1761..3bd941dd0f 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -53,3 +53,8 @@ export type BackendServeOptions = BundlingPathsOptions & { checksEnabled: boolean; inspectEnabled: boolean; }; + +export type LernaPackage = { + name: string; + location: string; +}; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 88aac1e33c..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/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 8653e66d37..3976998998 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -67,6 +67,11 @@ type Options = { */ buildDependencies?: boolean; + /** + * When `buildDependencies` is set, this list of packages will not be built even if they are dependencies. + */ + buildExcludes?: string[]; + /** * Enable (true/false) or control amount of (number) parallelism in some build steps. */ @@ -76,7 +81,7 @@ type Options = { * If set, creates a skeleton tarball that contains all package.json files * with the same structure as the workspace dir. */ - skeleton?: 'skeleton.tar'; + skeleton?: 'skeleton.tar' | 'skeleton.tar.gz'; }; /** @@ -85,7 +90,7 @@ type Options = { * will be suitable for packaging e.g. into a docker image. * * This creates a structure that is functionally similar to if the packages where - * installed from NPM, but uses yarn workspaces to link to them at runtime. + * installed from npm, but uses Yarn workspaces to link to them at runtime. */ export async function createDistWorkspace( packageNames: string[], @@ -98,7 +103,11 @@ export async function createDistWorkspace( const targets = await findTargetPackages(packageNames); if (options.buildDependencies) { - const scopeArgs = targets.flatMap(target => ['--scope', target.name]); + const exclude = options.buildExcludes ?? []; + const scopeArgs = targets + .filter(target => !exclude.includes(target.name)) + .flatMap(target => ['--scope', target.name]); + const lernaArgs = options.parallel && Number.isInteger(options.parallel) ? ['--concurrency', options.parallel.toString()] @@ -131,6 +140,7 @@ export async function createDistWorkspace( cwd: targetDir, portable: true, noMtime: true, + gzip: options.skeleton.endsWith('.gz'), } as CreateOptions & { noMtime: boolean }, skeletonFiles, ); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index fd7c189b40..f567786b7c 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -37,7 +37,7 @@ type LockfileQueryEntry = { version: string; }; -/** Entries that have an invalid version range, for example an NPM tag */ +/** Entries that have an invalid version range, for example an npm tag */ type AnalyzeResultInvalidRange = { name: string; range: string; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index b0f1c46e8a..5af040809a 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -19,6 +19,7 @@ import path from 'path'; import * as runObj from '../run'; import { paths } from '../paths'; import { fetchPackageInfo, mapDependencies } from './packages'; +import { NotFoundError } from '../errors'; describe('fetchPackageInfo', () => { afterEach(() => { @@ -40,6 +41,14 @@ describe('fetchPackageInfo', () => { 'my-package', ); }); + + it('should throw if no info', async () => { + jest.spyOn(runObj, 'runPlain').mockResolvedValue(''); + + await expect(fetchPackageInfo('my-package')).rejects.toThrow( + new NotFoundError(`No package information found for package my-package`), + ); + }); }); describe('mapDependencies', () => { diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 76e5dc49b0..991a4d23ef 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -15,6 +15,7 @@ */ import { runPlain } from '../../lib/run'; +import { NotFoundError } from '../errors'; const PREFIX = '@backstage'; @@ -49,6 +50,11 @@ export async function fetchPackageInfo( name: string, ): Promise { const output = await runPlain('yarn', 'info', '--json', name); + + if (!output) { + throw new NotFoundError(`No package information found for package ${name}`); + } + const info = JSON.parse(output) as YarnInfo; if (info.type !== 'inspect') { throw new Error(`Received unknown yarn info for ${name}, ${output}`); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs index e08f1650d5..5f90f2de1e 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { Typography, Grid } from '@material-ui/core'; import { InfoCard, @@ -11,7 +11,7 @@ import { } from '@backstage/core'; import ExampleFetchComponent from '../ExampleFetchComponent'; -const ExampleComponent: FC<{}> = () => ( +const ExampleComponent = () => (
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs index 0af27a5935..8cc5ed2ab7 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { Table, TableColumn, Progress } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; @@ -38,7 +38,7 @@ type DenseTableProps = { users: User[]; }; -export const DenseTable: FC = ({ users }) => { +export const DenseTable = ({ users }: DenseTableProps) => { const classes = useStyles(); const columns: TableColumn[] = [ @@ -73,7 +73,7 @@ export const DenseTable: FC = ({ users }) => { ); }; -const ExampleFetchComponent: FC<{}> = () => { +const ExampleFetchComponent = () => { const { value, loading, error } = useAsync(async (): Promise => { const response = await fetch('https://randomuser.me/api/?results=20'); const data = await response.json(); diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 729dc81206..a5e34ad91e 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,40 @@ # @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 + +- 4e7091759: Fix typo of "visibility" in config schema reference + + If you have defined a config element named `visiblity`, you + will need to fix the spelling to `visibility`. For more info, + see https://backstage.io/docs/conf/defining#visibility. + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError + ## 0.3.0 ### Minor Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 1584a61df5..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.3.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.43.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/config/CHANGELOG.md b/packages/config/CHANGELOG.md new file mode 100644 index 0000000000..06a9d8e4f1 --- /dev/null +++ b/packages/config/CHANGELOG.md @@ -0,0 +1,8 @@ +# @backstage/config + +## 0.1.2 + +### Patch Changes + +- e3bd9fc2f: Fix unneeded defensive code +- e3bd9fc2f: Fix useless conditional diff --git a/packages/config/package.json b/packages/config/package.json index f1ae07ef64..4e6c2f7157 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1", + "version": "0.1.2", "private": false, "publishConfig": { "access": "public", diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index eb8c91e366..c690e0bbc9 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -69,7 +69,7 @@ export class ConfigReader implements Config { constructor( private readonly data: JsonObject | undefined, - private readonly context: string = 'empty-config', + private readonly context: string = 'mock-config', private readonly fallback?: ConfigReader, private readonly prefix: string = '', ) {} @@ -272,23 +272,17 @@ export class ConfigReader implements Config { if (value === undefined) { return this.fallback?.readConfigValue(key, validate); } - if (value !== undefined) { - const result = validate(value); - if (result !== true) { - const { - key: keyName = key, - value: theValue = value, + const result = validate(value); + if (result !== true) { + const { key: keyName = key, value: theValue = value, expected } = result; + throw new TypeError( + errors.type( + this.fullKey(keyName), + this.context, + typeOf(theValue), expected, - } = result; - throw new TypeError( - errors.type( - this.fullKey(keyName), - this.context, - typeOf(theValue), - expected, - ), - ); - } + ), + ); } return value as T; diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 79bed2dfa3..a40e25b2a8 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,78 @@ # @backstage/core-api +## 0.2.9 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + +## 0.2.8 + +### Patch Changes + +- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. +- 86c3c652a: Deprecate `RouteRef` path parameter and member, and remove deprecated `routeRef.createSubRouteRef`. +- 27f2af935: Delay auth loginPopup close to avoid race condition with callers of authFlowHelpers. + +## 0.2.7 + +### Patch Changes + +- d681db2b5: Fix for GitHub and SAML auth not properly updating session state when already logged in. +- 1dc445e89: Introduce new plugin extension API +- Updated dependencies [1dc445e89] + - @backstage/test-utils@0.1.6 + +## 0.2.6 + +### Patch Changes + +- 7dd2ef7d1: Use auth provider ID to create unique session storage keys for GitHub and SAML Auth. + +## 0.2.5 + +### Patch Changes + +- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + + This fixes for example the following code: + + ```ts + interface MyApi { + myMethod(): void + } + + const myApiRef = createApiRef({...}); + + createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), + }) + ``` + +- d8d5a17da: Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. + + Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. + + Add an as of yet unused `params` option to `createRouteRef`. + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1665ae8bb] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + +## 0.2.4 + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError + - @backstage/test-utils@0.1.4 + ## 0.2.3 ### Patch Changes @@ -55,7 +128,7 @@ ![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) -- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the default scope is configurable when overwriting the Core Api in the app. ``` diff --git a/packages/core-api/package.json b/packages/core-api/package.json index fe56d11b9e..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.3", + "version": "0.2.9", "private": false, "publishConfig": { "access": "public", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/test-utils": "^0.1.3", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.2", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", + "@types/prop-types": "^15.7.3", "prop-types": "^15.7.2", "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", @@ -42,7 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.3.2", + "@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/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 2ce972af0e..459a361cf7 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -16,7 +16,10 @@ import { ApiRef, createApiRef } from '../system'; import { Config } from '@backstage/config'; -// Using interface to make the ConfigApi name show up in docs +/** + * The Config API is used to provide a mechanism to access the + * runtime configuration of the system. + */ export type ConfigApi = Config; export const configApiRef: ApiRef = createApiRef({ diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts index 9777835ec8..f62a97b61a 100644 --- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts @@ -31,7 +31,7 @@ export type DiscoveryApi = { /** * Returns the HTTP base backend URL for a given plugin, without a trailing slash. * - * This method must always be called just before making a request. as opposed to + * This method must always be called just before making a request, as opposed to * fetching the URL when constructing an API client. That is to ensure that more * flexible routing patterns can be supported. * diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index edd6966968..6205f8e058 100644 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -18,7 +18,7 @@ import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; /** - * Mirrors the javascript Error class, for the purpose of + * Mirrors the JavaScript Error class, for the purpose of * providing documentation and optional fields. */ type Error = { @@ -42,10 +42,10 @@ export type ErrorContext = { * to report errors for collection by error reporting services. * * If an error can be displayed inline, e.g. as feedback in a form, that should be - * preferred over relying on this API to display the error. The main use of this api + * preferred over relying on this API to display the error. The main use of this API * for displaying errors should be for asynchronous errors, such as a failing background process. * - * Even if an error is displayed inline, it should still be reported through this api + * Even if an error is displayed inline, it should still be reported through this API * if it would be useful to collect or log it for debugging purposes, but with * the hidden flag set. For example, an error arising from form field validation * should probably not be reported, while a failed REST call would be useful to report. diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 5cfc4723e0..2d29709b02 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -30,6 +30,7 @@ export type IdentityApi = { */ getUserId(): string; + // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. /** * The profile of the signed in user. */ @@ -43,8 +44,6 @@ export type IdentityApi = { */ getIdToken(): Promise; - // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. - /** * Sign out the current user */ diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 678dce9e32..e29d1022c4 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -25,9 +25,9 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; export * from './ConfigApi'; +export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; -export * from './DiscoveryApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts index 6287f35015..e616ece5ae 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -19,7 +19,7 @@ import { Observable } from '../../../types'; type RequestQueueEntry = { scopes: Set; - resolve: (value?: ResultType | PromiseLike | undefined) => void; + resolve: (value: ResultType | PromiseLike) => void; reject: (reason: Error) => void; }; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index 532bff3480..a7c945a907 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -45,7 +45,7 @@ export type GithubAuthResponse = { const DEFAULT_PROVIDER = { id: 'github', - title: 'Github', + title: 'GitHub', icon: GithubIcon, }; @@ -84,7 +84,7 @@ class GithubAuth implements OAuthApi, SessionApi { const authSessionStore = new AuthSessionStore({ manager: sessionManager, - storageKey: 'githubSession', + storageKey: `${provider.id}Session`, sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index 8669dff022..3f4bc814e3 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -21,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'gitlab', - title: 'Gitlab', + title: 'GitLab', icon: GitlabIcon, }; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index da5f082c9b..e05f6266d6 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -63,7 +63,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { const authSessionStore = new AuthSessionStore({ manager: sessionManager, - storageKey: 'samlSession', + storageKey: `${provider.id}Session`, }); return new SamlAuth(authSessionStore); diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 556f63589c..9d9b058eac 100644 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -55,9 +55,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { * A factory will not be added to the registry if there is already * an existing factory with the same or higher priority. */ - register( + register( scope: ApiFactoryScope, - factory: ApiFactory, + factory: ApiFactory, ) { const priority = ScopePriority[scope]; const existing = this.factories.get(factory.api); @@ -69,12 +69,14 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { return true; } - get(api: ApiRef): ApiFactory | undefined { + get( + api: ApiRef, + ): ApiFactory | undefined { const tuple = this.factories.get(api); if (!tuple) { return undefined; } - return tuple.factory as ApiFactory; + return tuple.factory as ApiFactory; } getAllApis(): Set { diff --git a/packages/core-api/src/apis/system/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx index f2aa70244e..91d35e5ee7 100644 --- a/packages/core-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import React, { FC, createContext, useContext, ReactNode } from 'react'; +import React, { + createContext, + useContext, + ReactNode, + PropsWithChildren, +} from 'react'; import PropTypes from 'prop-types'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { ApiAggregator } from './ApiAggregator'; @@ -26,7 +31,10 @@ type ApiProviderProps = { const Context = createContext(undefined); -export const ApiProvider: FC = ({ apis, children }) => { +export const ApiProvider = ({ + apis, + children, +}: PropsWithChildren) => { const parentHolder = useContext(Context); const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; @@ -62,7 +70,7 @@ export function withApis(apis: TypesToApiRefs) { return function withApisWrapper

( WrappedComponent: React.ComponentType

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

), + mountPoint: plugin2RouteRef, + }), + ); + + const ExposedComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const routeRefFunction = useRouteRef(externalRouteRef); + return
Our Route Is: {routeRefFunction({})}
; + }), + mountPoint: plugin1RouteRef, + }), + ); + + it('runs happy path', async () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { foo: plugin2RouteRef }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('Our Route Is: /foo/bar')).toBeInTheDocument(); + }); + + it('should throw some error when the route has duplicate params', () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + + const app = new PrivateAppImpl({ + apis: [], + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultSystemIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { foo: plugin2RouteRef }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + const { error: errorLogs } = withLogCollector(() => { + expect(() => + render( + + + + + + + + + , + ), + ).toThrow( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ); + }); + expect(errorLogs).toEqual([ + expect.stringContaining( + 'Parameter :thing is duplicated in path /test/:thing/some/:thing', + ), + expect.stringContaining( + 'The above error occurred in the component', + ), + ]); + }); +}); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 3d32259337..2aaeba9796 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,57 +13,95 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { ComponentType, - FC, + PropsWithChildren, + ReactElement, useMemo, useState, - ReactElement, } from 'react'; -import { Route, Routes, Navigate } from 'react-router-dom'; -import { AppContextProvider } from './AppContext'; -import { - BackstageApp, - AppComponents, - AppConfigLoader, - SignInResult, - SignInPageProps, -} from './types'; -import { BackstagePlugin } from '../plugin'; -import { - featureFlagsApiRef, - AppThemeApi, - ConfigApi, - identityApiRef, -} from '../apis/definitions'; -import { AppThemeProvider } from './AppThemeProvider'; - -import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAsync } from 'react-use'; import { + AnyApiFactory, + ApiHolder, ApiProvider, ApiRegistry, AppTheme, - AppThemeSelector, appThemeApiRef, + AppThemeSelector, configApiRef, ConfigReader, - useApi, - AnyApiFactory, - ApiHolder, LocalStorageFeatureFlags, + useApi, } from '../apis'; -import { useAsync } from 'react-use'; +import { + AppThemeApi, + ConfigApi, + featureFlagsApiRef, + identityApiRef, +} from '../apis/definitions'; +import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; +import { BackstagePlugin } from '../plugin'; +import { RouteRef } from '../routing'; +import { + routeObjectCollector, + routeParentCollector, + routePathCollector, +} from '../routing/collectors'; +import { RoutingProvider, validateRoutes } from '../routing/hooks'; +import { ExternalRouteRef } from '../routing/RouteRef'; +import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; -import { ApiResolver, ApiFactoryRegistry } from '../apis/system'; +import { AppThemeProvider } from './AppThemeProvider'; +import { + AppComponents, + AppConfigLoader, + AppOptions, + AppRouteBinder, + BackstageApp, + SignInPageProps, + SignInResult, +} from './types'; + +export function generateBoundRoutes( + bindRoutes: AppOptions['bindRoutes'], +): Map { + const result = new Map(); + + if (bindRoutes) { + const bind: AppRouteBinder = (externalRoutes, targetRoutes) => { + for (const [key, value] of Object.entries(targetRoutes)) { + const externalRoute = externalRoutes[key]; + if (!externalRoute) { + throw new Error(`Key ${key} is not an existing external route`); + } + + result.set(externalRoute, value); + } + }; + bindRoutes({ bind }); + } + + return result; +} type FullAppOptions = { apis: Iterable; icons: SystemIcons; - plugins: BackstagePlugin[]; + plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; configLoader?: AppConfigLoader; defaultApis: Iterable; + bindRoutes?: AppOptions['bindRoutes']; }; function useConfigLoader( @@ -107,11 +145,12 @@ export class PrivateAppImpl implements BackstageApp { private readonly apis: Iterable; private readonly icons: SystemIcons; - private readonly plugins: BackstagePlugin[]; + private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; private readonly defaultApis: Iterable; + private readonly bindRoutes: AppOptions['bindRoutes']; private readonly identityApi = new AppIdentity(); @@ -123,9 +162,10 @@ export class PrivateAppImpl implements BackstageApp { this.themes = options.themes; this.configLoader = options.configLoader; this.defaultApis = options.defaultApis; + this.bindRoutes = options.bindRoutes; } - getPlugins(): BackstagePlugin[] { + getPlugins(): BackstagePlugin[] { return this.plugins; } @@ -196,12 +236,28 @@ export class PrivateAppImpl implements BackstageApp { } getProvider(): ComponentType<{}> { - const Provider: FC<{}> = ({ children }) => { + const Provider = ({ children }: PropsWithChildren<{}>) => { const appThemeApi = useMemo( () => AppThemeSelector.createWithStorage(this.themes), [], ); + const { routePaths, routeParents, routeObjects } = useMemo(() => { + const result = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + validateRoutes(result.routePaths, result.routeParents); + + return result; + }, [children]); + const loadedConfig = useConfigLoader( this.configLoader, this.components, @@ -218,7 +274,16 @@ export class PrivateAppImpl implements BackstageApp { return ( - {children} + + + {children} + + ); @@ -233,10 +298,13 @@ export class PrivateAppImpl implements BackstageApp { } = this.components; // This wraps the sign-in page and waits for sign-in to be completed before rendering the app - const SignInPageWrapper: FC<{ + const SignInPageWrapper = ({ + component: Component, + children, + }: { component: ComponentType; children: ReactElement; - }> = ({ component: Component, children }) => { + }) => { const [result, setResult] = useState(); if (result) { @@ -247,7 +315,7 @@ export class PrivateAppImpl implements BackstageApp { return ; }; - const AppRouter: FC<{}> = ({ children }) => { + const AppRouter = ({ children }: PropsWithChildren<{}>) => { const configApi = useApi(configApiRef); let { pathname } = new URL( diff --git a/packages/core-api/src/app/AppContext.tsx b/packages/core-api/src/app/AppContext.tsx index ec3831992f..e0659d9e29 100644 --- a/packages/core-api/src/app/AppContext.tsx +++ b/packages/core-api/src/app/AppContext.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { createContext, useContext, FC } from 'react'; +import React, { createContext, PropsWithChildren, useContext } from 'react'; import { BackstageApp } from './types'; const Context = createContext(undefined); @@ -23,7 +23,10 @@ type Props = { app: BackstageApp; }; -export const AppContextProvider: FC = ({ app, children }) => ( +export const AppContextProvider = ({ + app, + children, +}: PropsWithChildren) => ( ); diff --git a/packages/core-api/src/app/AppThemeProvider.tsx b/packages/core-api/src/app/AppThemeProvider.tsx index 6bbcaea93a..993de23a7a 100644 --- a/packages/core-api/src/app/AppThemeProvider.tsx +++ b/packages/core-api/src/app/AppThemeProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useMemo, useEffect, useState } from 'react'; +import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; import { ThemeProvider, CssBaseline } from '@material-ui/core'; import { useApi, appThemeApiRef, AppTheme } from '../apis'; import { useObservable } from 'react-use'; @@ -68,7 +68,7 @@ const useShouldPreferDarkTheme = () => { return shouldPreferDark; }; -export const AppThemeProvider: FC<{}> = ({ children }) => { +export function AppThemeProvider({ children }: PropsWithChildren<{}>) { const appThemeApi = useApi(appThemeApiRef); const themeId = useObservable( appThemeApi.activeThemeId$(), @@ -94,4 +94,4 @@ export const AppThemeProvider: FC<{}> = ({ children }) => { {children} ); -}; +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 8a1eb92e8e..b6b1002d12 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -16,7 +16,8 @@ import { ComponentType } from 'react'; import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; -import { BackstagePlugin } from '../plugin'; +import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types'; +import { RouteRef } from '../routing'; import { AnyApiFactory } from '../apis'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; @@ -78,6 +79,11 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; +export type AppRouteBinder = ( + externalRoutes: T, + targetRoutes: { [key in keyof T]: RouteRef }, +) => void; + export type AppOptions = { /** * A collection of ApiFactories to register in the application to either @@ -93,7 +99,7 @@ export type AppOptions = { /** * A list of all plugins to include in the app. */ - plugins?: BackstagePlugin[]; + plugins?: BackstagePlugin[]; /** * Supply components to the app to override the default ones. @@ -134,13 +140,33 @@ export type AppOptions = { * that was packaged by the backstage-cli and default docker container boot script. */ configLoader?: AppConfigLoader; + + /** + * A function that is used to register associations between cross-plugin route + * references, enabling plugins to navigate between each other. + * + * The `bind` function that is passed in should be used to bind all external + * routes of all used plugins. + * + * ```ts + * bindRoutes({ bind }) { + * bind(docsPlugin.externalRoutes, { + * homePage: managePlugin.routes.managePage, + * }) + * bind(homePagePlugin.externalRoutes, { + * settingsPage: settingsPlugin.routes.settingsPage, + * }) + * } + * ``` + */ + bindRoutes?(context: { bind: AppRouteBinder }): void; }; export type BackstageApp = { /** * Returns all plugins registered for the app. */ - getPlugins(): BackstagePlugin[]; + getPlugins(): BackstagePlugin[]; /** * Get a common icon for this app. diff --git a/packages/core-api/src/extensions/componentData.test.tsx b/packages/core-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..6c4abda40f --- /dev/null +++ b/packages/core-api/src/extensions/componentData.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { attachComponentData, getComponentData } from './componentData'; + +describe('elementData', () => { + it('should attach a single piece of data', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should attach several distinct pieces of data', () => { + const data1 = { foo: 'bar' }; + const data2 = { test: 'value' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data1); + attachComponentData(Component, 'second', data2); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data1); + expect(getComponentData(element, 'second')).toBe(data2); + }); + + it('returns undefined for missing data', () => { + const data = { foo: 'bar' }; + const Component1 = () => null; + const Component2 = () => null; + attachComponentData(Component2, 'my-data', data); + + const element1 = ; + const element2 = ; + expect(getComponentData(element1, 'missing')).toBeUndefined(); + expect(getComponentData(element2, 'missing')).toBeUndefined(); + }); + + it('should throw when attempting to overwrite data', () => { + const data = { foo: 'bar' }; + const MyComponent = () => null; + attachComponentData(MyComponent, 'my-data', data); + expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow( + 'Attempted to attach duplicate data "my-data" to component "MyComponent"', + ); + }); +}); diff --git a/packages/core-api/src/extensions/componentData.tsx b/packages/core-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..1f88356a48 --- /dev/null +++ b/packages/core-api/src/extensions/componentData.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType, ReactNode } from 'react'; + +const DATA_KEY = Symbol('backstage-component-data'); + +type DataContainer = { + map: Map; +}; + +type ComponentWithData

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type ReactNodeWithData = ReactNode & { + type?: { [DATA_KEY]?: DataContainer }; +}; + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + let container = dataComponent[DATA_KEY]; + if (!container) { + container = dataComponent[DATA_KEY] = { map: new Map() }; + } + + if (container.map.has(type)) { + const name = component.displayName || component.name; + throw new Error( + `Attempted to attach duplicate data "${type}" to component "${name}"`, + ); + } + + container.map.set(type, data); +} + +export function getComponentData( + node: ReactNode, + type: string, +): T | undefined { + if (!node) { + return undefined; + } + + const container = (node as ReactNodeWithData).type?.[DATA_KEY]; + if (!container) { + return undefined; + } + + return container.map.get(type) as T | undefined; +} diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx new file mode 100644 index 0000000000..26755b3bcf --- /dev/null +++ b/packages/core-api/src/extensions/extensions.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createPlugin } from '../plugin'; +import { createRouteRef } from '../routing'; +import { getComponentData } from './componentData'; +import { + createComponentExtension, + createReactExtension, + createRoutableExtension, +} from './extensions'; + +const plugin = createPlugin({ + id: 'my-plugin', +}); + +describe('extensions', () => { + it('should create a react extension with component data', () => { + const Component = () =>

; + + const extension = createReactExtension({ + component: { + sync: Component, + }, + data: { + myData: { foo: 'bar' }, + }, + }); + + const ExtensionComponent = plugin.provide(extension); + const element = ; + + expect(getComponentData(element, 'core.plugin')).toBe(plugin); + expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' }); + }); + + it('should create react extensions of different types', () => { + const Component = () =>
; + const routeRef = createRouteRef({ path: '/foo', title: 'Foo' }); + + const extension1 = createComponentExtension({ + component: { + sync: Component, + }, + }); + + const extension2 = createRoutableExtension({ + component: () => Promise.resolve(Component), + mountPoint: routeRef, + }); + + const ExtensionComponent1 = plugin.provide(extension1); + const ExtensionComponent2 = plugin.provide(extension2); + + const element1 = ; + const element2 = ; + + expect(getComponentData(element1, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); + }); +}); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..e56b6901dd --- /dev/null +++ b/packages/core-api/src/extensions/extensions.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { lazy, Suspense } from 'react'; +import { RouteRef, useRouteRef } from '../routing'; +import { attachComponentData } from './componentData'; +import { Extension, BackstagePlugin } from '../plugin/types'; + +type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + +export function createRoutableExtension< + T extends (props: any) => JSX.Element +>(options: { + component: () => Promise; + mountPoint: RouteRef; +}): Extension { + const { component, mountPoint } = options; + return createReactExtension({ + component: { + lazy: () => + component().then(InnerComponent => { + const RoutableExtensionWrapper = ((props: any) => { + // Validate that the routing is wired up correctly in the App.tsx + try { + useRouteRef(mountPoint); + } catch { + throw new Error( + 'Routable extension component was not discovered in the app element tree. ' + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + return ; + }) as T; + return RoutableExtensionWrapper; + }), + }, + data: { + 'core.mountPoint': mountPoint, + }, + }); +} + +export function createComponentExtension< + T extends (props: any) => JSX.Element +>(options: { component: ComponentLoader }): Extension { + const { component } = options; + return createReactExtension({ component }); +} + +export function createReactExtension< + T extends (props: any) => JSX.Element +>(options: { + component: ComponentLoader; + data?: Record; +}): Extension { + const { data = {} } = options; + + let Component: T; + if ('lazy' in options.component) { + const lazyLoader = options.component.lazy; + Component = (lazy(() => + lazyLoader().then(component => ({ default: component })), + ) as unknown) as T; + } else { + Component = options.component.sync; + } + const componentName = + (Component as { displayName?: string }).displayName || + Component.name || + 'Component'; + + return { + expose(plugin: BackstagePlugin) { + const Result: any = (props: any) => ( + + + + ); + + attachComponentData(Result, 'core.plugin', plugin); + for (const [key, value] of Object.entries(data)) { + attachComponentData(Result, key, value); + } + + Result.displayName = `Extension(${componentName})`; + return Result; + }, + }; +} diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/core-api/src/extensions/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { attachComponentData, getComponentData } from './componentData'; +export { + createReactExtension, + createRoutableExtension, + createComponentExtension, +} from './extensions'; diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx new file mode 100644 index 0000000000..38571fdcc7 --- /dev/null +++ b/packages/core-api/src/extensions/traversal.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Children, isValidElement } from 'react'; +import { + childDiscoverer, + createCollector, + traverseElementTree, +} from './traversal'; + +describe('discovery', () => { + it('should collect element names', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [childDiscoverer], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual([ + 'main', + 'div', + 'hr', + 'div', + 'h1', + 'p', + 'h2', + 'span', + ]); + }); + + it('should collect element names while skipping one level of children', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [ + el => + Children.toArray(el.props.children).flatMap(child => + isValidElement(child) ? child?.props?.children : [], + ), + ], + collectors: { + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), + }, + }); + + expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); + }); +}); diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts new file mode 100644 index 0000000000..bdf02d16c8 --- /dev/null +++ b/packages/core-api/src/extensions/traversal.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidElement, ReactNode, ReactElement, Children } from 'react'; + +export type Discoverer = (element: ReactElement) => ReactNode; + +export type Collector = () => { + accumulator: Result; + visit( + accumulator: Result, + element: ReactElement, + parent: ReactElement | undefined, + context: Context, + ): Context; +}; + +/** + * A function that allows you to traverse a tree of React elements using + * varying methods to discover child nodes and collect data along the way. + */ +export function traverseElementTree(options: { + root: ReactNode; + discoverers: Discoverer[]; + collectors: { [name in keyof Results]: Collector }; +}): Results { + const visited = new Set(); + const collectors: { + [name in string]: ReturnType>; + } = {}; + + // Bootstrap all collectors, initializing the accumulators and providing the visitor function + for (const name in options.collectors) { + if (options.collectors.hasOwnProperty(name)) { + collectors[name] = options.collectors[name](); + } + } + + // Internal representation of an element in the tree that we're iterating over + type QueueItem = { + node: ReactNode; + parent: ReactElement | undefined; + contexts: { [name in string]: unknown }; + }; + + const queue = [ + { + node: Children.toArray(options.root), + parent: undefined, + contexts: {}, + } as QueueItem, + ]; + + while (queue.length !== 0) { + const { node, parent, contexts } = queue.shift()!; + + // While the parent and the element we pass on to collectors and discoverers + // have been validated and are known to be React elements, the child nodes + // emitted by the discoverers are not. + Children.forEach(node, element => { + if (!isValidElement(element)) { + return; + } + if (visited.has(element)) { + const anyType = element?.type as + | { displayName?: string; name?: string } + | undefined; + const name = anyType?.displayName || anyType?.name || String(anyType); + throw new Error(`Visited element ${name} twice`); + } + visited.add(element); + + const nextContexts: QueueItem['contexts'] = {}; + + // Collectors populate their result data using the current node, and compute + // context for the next iteration + for (const name in collectors) { + if (collectors.hasOwnProperty(name)) { + const collector = collectors[name]; + + nextContexts[name] = collector.visit( + collector.accumulator, + element, + parent, + contexts[name], + ); + } + } + + // Discoverers provide ways to continue the traversal from the current element + for (const discoverer of options.discoverers) { + const children = discoverer(element); + if (children) { + queue.push({ + node: children, + parent: element, + contexts: nextContexts, + }); + } + } + }); + } + + return Object.fromEntries( + Object.entries(collectors).map(([name, c]) => [name, c.accumulator]), + ) as Results; +} + +export function createCollector( + accumulatorFactory: () => Result, + visit: ReturnType>['visit'], +): Collector { + return () => ({ accumulator: accumulatorFactory(), visit }); +} + +export function childDiscoverer(element: ReactElement): ReactNode { + return element.props?.children; +} + +export function routeElementDiscoverer(element: ReactElement): ReactNode { + if (element.props?.path && element.props?.element) { + return element.props?.element; + } + return undefined; +} diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx index 488973b664..50c4b68e43 100644 --- a/packages/core-api/src/icons/icons.tsx +++ b/packages/core-api/src/icons/icons.tsx @@ -17,7 +17,7 @@ import { SvgIconProps } from '@material-ui/core'; import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; -import React, { FC } from 'react'; +import React from 'react'; import { useApp } from '../app/AppContext'; import { IconComponent, SystemIconKey, SystemIcons } from './types'; @@ -27,7 +27,7 @@ export const defaultSystemIcons: SystemIcons = { }; const overridableSystemIcon = (key: SystemIconKey): IconComponent => { - const Component: FC = props => { + const Component = (props: SvgIconProps) => { const app = useApp(); const Icon = app.getSystemIcon(key); return ; diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts index 4572b923dd..5c960f7876 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -36,6 +36,7 @@ class LocalStorage { } class MockManager implements SessionManager { + setSession = jest.fn(); getSession = jest.fn(); removeSession = jest.fn(); sessionState$ = jest.fn(); @@ -59,6 +60,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('a b c'); expect(manager.getSession).not.toHaveBeenCalled(); + expect(manager.setSession).toHaveBeenCalledWith('a b c'); }); it('should not use session without enough scope', async () => { @@ -72,6 +74,7 @@ describe('GheAuth AuthSessionStore', () => { 'a b c d', ); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should not use expired session', async () => { @@ -87,6 +90,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should not load missing session', async () => { @@ -96,6 +100,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); expect(localStorage.getItem('my-key')).toBe('"123"'); }); @@ -109,6 +114,7 @@ describe('GheAuth AuthSessionStore', () => { await expect(store.getSession({})).resolves.toBe('123'); expect(manager.getSession).toHaveBeenCalledTimes(1); + expect(manager.setSession).not.toHaveBeenCalled(); }); it('should clear session', () => { @@ -119,6 +125,7 @@ describe('GheAuth AuthSessionStore', () => { store.removeSession(); expect(localStorage.getItem('my-key')).toBe(null); + expect(manager.removeSession).toHaveBeenCalled(); }); it('should forward sessionState calls', () => { diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index e82557b1ce..224036d283 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -16,6 +16,7 @@ import { SessionManager, + MutableSessionManager, SessionScopesFunc, SessionShouldRefreshFunc, GetSessionOptions, @@ -24,7 +25,7 @@ import { SessionScopeHelper } from './common'; type Options = { /** The connector used for acting on the auth session */ - manager: SessionManager; + manager: MutableSessionManager; /** Storage key to use to store sessions */ storageKey: string; /** Used to get the scope of the session */ @@ -40,7 +41,7 @@ type Options = { * Session is serialized to JSON with special support for following types: Set. */ export class AuthSessionStore implements SessionManager { - private readonly manager: SessionManager; + private readonly manager: MutableSessionManager; private readonly storageKey: string; private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; private readonly helper: SessionScopeHelper; @@ -70,6 +71,7 @@ export class AuthSessionStore implements SessionManager { const shouldRefresh = this.sessionShouldRefreshFunc(session!); if (!shouldRefresh) { + this.manager.setSession(session!); return session!; } } diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index e02b600828..e4f144b0a9 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionManager, GetSessionOptions } from './types'; +import { MutableSessionManager, GetSessionOptions } from './types'; import { AuthConnector } from '../AuthConnector'; import { SessionScopeHelper } from './common'; import { SessionStateTracker } from './SessionStateTracker'; @@ -31,7 +31,7 @@ type Options = { /** * StaticAuthSessionManager manages an underlying session that does not expire. */ -export class StaticAuthSessionManager implements SessionManager { +export class StaticAuthSessionManager implements MutableSessionManager { private readonly connector: AuthConnector; private readonly helper: SessionScopeHelper; private readonly stateTracker = new SessionStateTracker(); @@ -45,6 +45,11 @@ export class StaticAuthSessionManager implements SessionManager { this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); } + setSession(session: T | undefined): void { + this.currentSession = session; + this.stateTracker.setIsSignedIn(Boolean(session)); + } + async getSession(options: GetSessionOptions): Promise { if ( this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index 804c7121e1..f57afd6760 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -36,6 +36,13 @@ export type SessionManager = { sessionState$(): Observable; }; +/** + * An extension of the session manager where the session can also be pushed from the manager. + */ +export interface MutableSessionManager extends SessionManager { + setSession(session: T | undefined): void; +} + /** * A function called to determine the scopes of a session. */ diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index d69bb7e721..cc168707be 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -14,13 +14,23 @@ * limitations under the License. */ -import { PluginConfig, PluginOutput, BackstagePlugin } from './types'; +import { + PluginConfig, + PluginOutput, + BackstagePlugin, + Extension, + AnyRoutes, + AnyExternalRoutes, +} from './types'; import { AnyApiFactory } from '../apis'; -export class PluginImpl { +export class PluginImpl< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> implements BackstagePlugin { private storedOutput?: PluginOutput[]; - constructor(private readonly config: PluginConfig) {} + constructor(private readonly config: PluginConfig) {} getId(): string { return this.config.id; @@ -30,6 +40,14 @@ export class PluginImpl { return this.config.apis ?? []; } + get routes(): Routes { + return this.config.routes ?? ({} as Routes); + } + + get externalRoutes(): ExternalRoutes { + return this.config.externalRoutes ?? ({} as ExternalRoutes); + } + output(): PluginOutput[] { if (this.storedOutput) { return this.storedOutput; @@ -50,9 +68,6 @@ export class PluginImpl { options, }); }, - registerRoute(path, component, options) { - outputs.push({ type: 'legacy-route', path, component, options }); - }, }, featureFlags: { register(name) { @@ -65,11 +80,20 @@ export class PluginImpl { return this.storedOutput; } + provide(extension: Extension): T { + return extension.expose(this); + } + toString() { return `plugin{${this.config.id}}`; } } -export function createPlugin(config: PluginConfig): BackstagePlugin { +export function createPlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +>( + config: PluginConfig, +): BackstagePlugin { return new PluginImpl(config); } diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx new file mode 100644 index 0000000000..5baf2539ab --- /dev/null +++ b/packages/core-api/src/plugin/collectors.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { createRouteRef } from '../routing'; +import { createPlugin } from './Plugin'; +import { + createRoutableExtension, + createComponentExtension, +} from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { pluginCollector } from './collectors'; + +const mockConfig = () => ({ path: '/foo', title: 'Foo' }); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const pluginA = createPlugin({ id: 'my-plugin-a' }); +const pluginB = createPlugin({ id: 'my-plugin-b' }); +const pluginC = createPlugin({ id: 'my-plugin-c' }); + +const ref1 = createRouteRef(mockConfig()); +const ref2 = createRouteRef(mockConfig()); + +const Extension1 = pluginA.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = pluginB.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = pluginA.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension4 = pluginB.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); +const Extension5 = pluginC.provide( + createComponentExtension({ component: { sync: MockComponent } }), +); + +describe('collection', () => { + it('should collect the plugins', () => { + const root = ( + + + +
+ +
+
+ {[]} + Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { plugins } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + plugins: pluginCollector, + }, + }); + + expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); + }); +}); diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts new file mode 100644 index 0000000000..b04c7b41c4 --- /dev/null +++ b/packages/core-api/src/plugin/collectors.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. + */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstagePlugin } from './types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +export const pluginCollector = createCollector( + () => new Set>(), + (acc, node) => { + const plugin = getComponentData>( + node, + 'core.plugin', + ); + if (plugin) { + acc.add(plugin); + } + }, +); diff --git a/packages/core-api/src/plugin/index.ts b/packages/core-api/src/plugin/index.ts index 79b0575755..bbeeca4824 100644 --- a/packages/core-api/src/plugin/index.ts +++ b/packages/core-api/src/plugin/index.ts @@ -15,4 +15,19 @@ */ export { createPlugin } from './Plugin'; -export * from './types'; +export type { + BackstagePlugin, + Extension, + FeatureFlagOutput, + FeatureFlagsHooks, + LegacyRedirectRouteOutput, + LegacyRouteOutput, + PluginConfig, + PluginHooks, + PluginOutput, + RedirectRouteOutput, + RouteOptions, + RouteOutput, + RoutePath, + RouterHooks, +} from './types'; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 855264c5f4..711246f1f4 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -17,6 +17,7 @@ import { ComponentType } from 'react'; import { RouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; +import { ExternalRouteRef } from '../routing/RouteRef'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. @@ -66,39 +67,54 @@ export type PluginOutput = | RedirectRouteOutput | FeatureFlagOutput; -export type BackstagePlugin = { +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; + +export type AnyRoutes = { [name: string]: RouteRef }; + +export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; + +export type BackstagePlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +> = { getId(): string; output(): PluginOutput[]; getApis(): Iterable; + provide(extension: Extension): T; + routes: Routes; + externalRoutes: ExternalRoutes; }; -export type PluginConfig = { +export type PluginConfig< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> = { id: string; apis?: Iterable; register?(hooks: PluginHooks): void; + routes?: Routes; + externalRoutes?: ExternalRoutes; }; export type PluginHooks = { + /** + * @deprecated All router hooks have been deprecated + */ router: RouterHooks; featureFlags: FeatureFlagsHooks; }; export type RouterHooks = { + /** + * @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins + */ addRoute( target: RouteRef, Component: ComponentType, options?: RouteOptions, ): void; - - /** - * @deprecated See the `addRoute` method - * @see https://github.com/backstage/backstage/issues/418 - */ - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; }; export type FeatureFlagsHooks = { diff --git a/packages/core-api/src/public.ts b/packages/core-api/src/public.ts index 0a30936c52..f91d97c31d 100644 --- a/packages/core-api/src/public.ts +++ b/packages/core-api/src/public.ts @@ -16,6 +16,7 @@ export * from './apis'; export * from './app'; +export * from './extensions'; export * from './icons'; export * from './plugin'; export * from './routing'; diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx new file mode 100644 index 0000000000..a6783964a7 --- /dev/null +++ b/packages/core-api/src/routing/FlatRoutes.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReactNode, Children, isValidElement, Fragment } from 'react'; +import { useRoutes } from 'react-router-dom'; + +type RouteObject = { + path: string; + element: JSX.Element; + children?: RouteObject[]; +}; + +// Similar to the same function from react-router, this collects routes from the +// children, but only the first level of routes +function createRoutesFromChildren(children: ReactNode): RouteObject[] { + return Children.toArray(children) + .flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + const { children } = child.props; + + if (child.type === Fragment) { + return createRoutesFromChildren(children); + } + + let path = child.props.path as string | undefined; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: children && [ + { + path: '/*', + element: children, + }, + ], + }, + ]; + }) + .sort((a, b) => b.path.localeCompare(a.path)) + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }); +} + +type FlatRoutesProps = { + children: ReactNode; +}; + +export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { + const routes = createRoutesFromChildren(props.children); + return useRoutes(routes); +}; diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index c33335cb38..4c2da34098 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,44 +14,10 @@ * limitations under the License. */ -import { - ConcreteRoute, - routeReference, - ReferencedRoute, - resolveRoute, - RouteRefConfig, -} from './types'; -import { generatePath } from 'react-router-dom'; +import { RouteRefConfig, RouteRef } from './types'; -type SubRouteConfig = { - path: string; -}; - -export class SubRouteRef - implements ReferencedRoute { - constructor( - private readonly parent: ConcreteRoute, - private readonly config: SubRouteConfig, - ) {} - - get [routeReference]() { - return this; - } - - link(...args: Args): ConcreteRoute { - return { - [routeReference]: this, - [resolveRoute]: (path: string) => { - const ownPart = generatePath(this.config.path, args[0] ?? {}); - const parentPart = this.parent[resolveRoute](path); - return parentPart + ownPart; - }, - }; - } -} - -export class AbsoluteRouteRef implements ConcreteRoute { - constructor(private readonly config: RouteRefConfig) {} +export class AbsoluteRouteRef { + constructor(private readonly config: RouteRefConfig) {} get icon() { return this.config.icon; @@ -59,33 +25,33 @@ export class AbsoluteRouteRef implements ConcreteRoute { // TODO(Rugvip): Remove this, routes are looked up via the registry instead get path() { - return this.config.path; + return this.config.path ?? ''; } get title() { return this.config.title; } - createSubRoute( - config: SubRouteConfig, - ) { - return new SubRouteRef(this, config); - } - - get [routeReference]() { - return this; - } - - [resolveRoute](path: string) { - return path; + toString() { + return `routeRef{title=${this.title}}`; } } -export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { - return new AbsoluteRouteRef(config); +export function createRouteRef< + ParamKeys extends string, + Params extends { [param in string]: string } = { [name in ParamKeys]: string } +>(config: RouteRefConfig): RouteRef { + return new AbsoluteRouteRef(config); } -// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone -// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider -// a different model for how to create sub routes, just avoid this -export type MutableRouteRef = AbsoluteRouteRef; +export class ExternalRouteRef { + private constructor() {} + + toString() { + return `externalRouteRef{}`; + } +} + +export function createExternalRouteRef(): ExternalRouteRef { + return new ((ExternalRouteRef as unknown) as { new (): ExternalRouteRef })(); +} diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts deleted file mode 100644 index fa1ef584f1..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { RouteRefRegistry } from './RouteRefRegistry'; -import { createRouteRef } from './RouteRef'; - -const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; -const ref1 = createRouteRef(dummyConfig); -const ref11 = createRouteRef(dummyConfig); -const ref12 = createRouteRef(dummyConfig); -const ref121 = createRouteRef(dummyConfig); -const ref2 = createRouteRef(dummyConfig); -const ref2a = ref2.createSubRoute({ path: '/a' }); -const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); - -describe('RouteRefRegistry', () => { - it('should be constructed with a root route', () => { - const registry = new RouteRefRegistry(); - expect(registry.resolveRoute([], [])).toBe(''); - }); - - it('should register and resolve some absolute routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); - expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( - false, - ); - expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref11])).toBe(undefined); - expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); - expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); - }); - - it('should register and resolve with sub routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2a], '2')).toBe(true); - expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2b], '2')).toBe(true); - expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref2])).toBe('/2'); - expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); - expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( - '/2/b/abc/1', - ); - expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( - '/2/b/xyz/2', - ); - expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( - '/2/b/abc/2', - ); - expect( - registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), - ).toBe('/2/b/abc/2'); - }); - - it('should throw when registering routes incorrectly', () => { - const registry = new RouteRefRegistry(); - expect(() => { - registry.registerRoute([ref1, ref11], '11'); - }).toThrow('Could not find parent for new routing node'); - expect(() => { - registry.registerRoute([], '11'); - }).toThrow('Must provide at least 1 route to add routing node'); - }); -}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts deleted file mode 100644 index 7e55cbe8f7..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ConcreteRoute, - routeReference, - resolveRoute, - ReferencedRoute, -} from './types'; - -const rootRoute: ConcreteRoute = { - get [routeReference]() { - return this; - }, - [resolveRoute]: () => '', -}; - -export type RouteRefResolver = { - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; -}; - -class Node { - readonly children = new Map(); - - constructor(readonly path: string, readonly parent: Node | undefined) {} - - /** - * Look up a node in the tree given a path. - */ - findNode(routes: ReferencedRoute[]): Node | undefined { - let node = this as Node | undefined; - - for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i][routeReference]); - } - - return node; - } - - /** - * Assigns a path to a leaf node in the routing tree. All ancestor - * nodes of the new leaf node must already exist, or an error will be thrown. - * - * Returns true if the node was added, or false if the node already existed. - */ - addNode(routes: ReferencedRoute[], path: string): boolean { - if (routes.length === 0) { - throw new Error('Must provide at least 1 route to add routing node'); - } - - const parentNode = this.findNode(routes.slice(0, -1)); - if (!parentNode) { - throw new Error('Could not find parent for new routing node'); - } - - const lastRoute = routes[routes.length - 1]; - const lastRouteRef = lastRoute[routeReference]; - - const existingNode = parentNode.children.get(lastRouteRef); - if (existingNode) { - return existingNode.path === path; - } - - parentNode.children.set(lastRouteRef, new Node(path, parentNode)); - return true; - } - - /** - * Resolve an absolute URL that represents this node in the routing tree, using - * using the supplied concrete routes and ancestors of this node. - * - * The length of the provided routes array must match the depth of - * the routing tree that this node is at, or an error will be thrown. - */ - resolve(routes: ConcreteRoute[]) { - const parts = Array(routes.length); - - let node = this as Node | undefined; - for (let i = routes.length - 1; i >= 0; i--) { - if (!node) { - throw new Error('Route resolve missing required parent'); - } - - const route = routes[i]; - parts[i] = route[resolveRoute](node.path); - - node = node.parent; - } - - if (node) { - throw new Error('Route resolve did not reach root'); - } - - return parts.join('/'); - } -} - -/** - * A registry for resolving route refs into concrete string routes. - */ -export class RouteRefRegistry { - private readonly root = new Node('', undefined); - - /** - * Register a new leaf path for a sequence of routes. All ancestor - * routes must already exist. - */ - registerRoute(routes: ReferencedRoute[], path: string): boolean { - return this.root.addNode(routes, path); - } - - /** - * Resolve an absolute path from a point in the routing tree. - * - * The route referenced by `from` must exist, and is the starting - * point for the search, walking up the tree until a subtree that - * matches the routes reference in `to` are found. - * - * If `from` is empty, the search starts and ends at the root node. - * If `to` is empty, the route referenced by `from` will always be returned. - */ - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { - // Keep track of the `from` routes and pop the last ones as we traverse up - // the routing tree. The list of concrete routes that we're passing to - // `node.resolve()` should only include the ones in the resolve path. - const concreteStack = from.slice(); - - let fromNode = this.root.findNode(from); - while (fromNode) { - const resolvedNode = fromNode.findNode(to); - if (resolvedNode) { - return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); - } - - // Search at this level of the tree failed, move up to parent - concreteStack.pop(); - fromNode = fromNode.parent; - } - - return undefined; - } -} diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx new file mode 100644 index 0000000000..44d53856b4 --- /dev/null +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -0,0 +1,376 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; + +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { createRouteRef } from './RouteRef'; +import { createPlugin } from '../plugin'; +import { attachComponentData, createRoutableExtension } from '../extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RouteRef } from './types'; + +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); +const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); +const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); +const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +const AggregationComponent = ({ + children, +}: PropsWithChildren<{ + path: string; +}>) => <>{children}; + +attachComponentData(AggregationComponent, 'core.gatherMountPoints', true); + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +function routeObj(path: string, refs: RouteRef[], children: any[] = []) { + return { + path: path, + caseSensitive: false, + element: null, + routeRefs: new Set(refs), + children: children, + }; +} + +describe('discovery', () => { + it('should collect routes', () => { + const list = [ +
, +
, +
+ +
, + ]; + + const root = ( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); + }); + + it('should handle all react router Route patterns', () => { + const root = ( + + + + + + + + } + /> + }> + } /> + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
+ ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2]), + routeObj('/bar', [ref3], [routeObj('/baz', [ref4, ref5])]), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); + }); + + it('should not visit the same element twice', () => { + const element = ; + + expect(() => + traverseElementTree({ + root: ( + + {element} + {element} + + ), + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }), + ).toThrow(`Visited element Extension(Component) twice`); + }); +}); diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx new file mode 100644 index 0000000000..37362cf8d4 --- /dev/null +++ b/packages/core-api/src/routing/collectors.tsx @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { BackstageRouteObject, RouteRef } from '../routing/types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), + (acc, node, parent, ctxPath: string | undefined) => { + // The context path is used during mount point gathering to assign the same path + // to all discovered mount points + let currentCtxPath = ctxPath; + + if (parent?.props.element === node) { + return currentCtxPath; + } + + // Start gathering mount points when we encounter a mount point gathering flag + if (getComponentData(node, 'core.gatherMountPoints')) { + const path: string | undefined = node.props?.path; + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + currentCtxPath = path; + } + + const routeRef = getMountPoint(node); + if (routeRef) { + let path: string | undefined = node.props?.path; + // If we're gathering mount points we use the context path as out path, unless + // the element has its own path, in which case we use that instead and stop gathering + if (currentCtxPath) { + if (path) { + currentCtxPath = undefined; + } else { + path = currentCtxPath; + } + } + if (!path) { + throw new Error('Mounted routable extension must have a path'); + } + acc.set(routeRef, path); + } + return currentCtxPath; + }, +); + +export const routeParentCollector = createCollector( + () => new Map(), + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { + if (parent?.props.element === node) { + return parentRouteRef; + } + + let nextParent = parentRouteRef; + + const routeRef = getMountPoint(node); + if (routeRef) { + // "sticky" route ref is when we've encountered a mount point gatherer, and we want a + // mount points beneath it to have the same parent, regardless of internal structure + if (parentRouteRef && 'sticky' in parentRouteRef) { + acc.set(routeRef, parentRouteRef.sticky); + + // When we encounter a mount point with an explicit path, we stop gathering + // mount points withing the children and remove the sticky state + if (node.props?.path) { + nextParent = routeRef; + } else { + nextParent = parentRouteRef; + } + } else { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; + } + } + + // Mount point gatherers are marked as "sticky" + if (getComponentData(node, 'core.gatherMountPoints')) { + return { sticky: nextParent }; + } + + return nextParent; + }, +); + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; + if (parent?.props.element === node) { + return parentObj; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set([routeRef]), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + parentObj?.routeRefs.add(routeRef); + } + + const isGatherer = getComponentData( + node, + 'core.gatherMountPoints', + ); + if (isGatherer) { + if (!path) { + throw new Error('Mount point gatherer must have a path'); + } + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set(), + children: [], + }; + parentChildren.push(newObject); + return newObject; + } + + return parentObj; + }, +); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx new file mode 100644 index 0000000000..3dbeaf8e3e --- /dev/null +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -0,0 +1,298 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React, { PropsWithChildren, ReactElement } from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { createPlugin } from '../plugin'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { + useRouteRef, + RoutingProvider, + validateRoutes, + RouteFunc, +} from './hooks'; +import { + createRouteRef, + createExternalRouteRef, + ExternalRouteRef, +} from './RouteRef'; +import { RouteRef, RouteRefConfig } from './types'; + +const mockConfig = (extra?: Partial>) => ({ + path: '/unused', + title: 'Unused', + ...extra, +}); +const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( + <>{children} +); + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); +const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); +const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); +const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); +const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); +const eRefA = createExternalRouteRef(); +const eRefB = createExternalRouteRef(); +const eRefC = createExternalRouteRef(); + +const MockRouteSource = (props: { + path?: string; + name: string; + routeRef: RouteRef | ExternalRouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef) as RouteFunc; + return ( +
+ Path at {props.name}: {routeFunc(props.params)} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref1, + }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref2, + }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref3, + }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockRouteSource), + mountPoint: ref4, + }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ + component: () => Promise.resolve(MockComponent), + mountPoint: ref5, + }), +); + +function withRoutingProvider( + root: ReactElement, + routeBindings: [ExternalRouteRef, RouteRef][] = [], +) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { + const root = ( + + + + + + + + + + + + + ); + + const rendered = render( + withRoutingProvider(root, [ + [eRefA, ref3], + [eRefB, ref1], + [eRefC, ref2], + ]), + ); + + await expect( + rendered.findByText('Path at inside: /foo/bar'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at insideExternal: /baz'), + ).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal1: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal2: /foo/bar'), + ).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', async () => { + const root = ( + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/bar/bleb'), + ).resolves.toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', async () => { + const root = ( + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routePaths, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routePaths, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); +}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx new file mode 100644 index 0000000000..3a18d8b0af --- /dev/null +++ b/packages/core-api/src/routing/hooks.tsx @@ -0,0 +1,196 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { createContext, ReactNode, useContext, useMemo } from 'react'; +import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types'; +import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; +import { ExternalRouteRef } from './RouteRef'; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params[keyof Params] extends never + ? readonly [] + : readonly [Params] +) => string; + +class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + private readonly routeBindings: Map, + ) {} + + resolve( + routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, + sourceLocation: ReturnType, + ): RouteFunc { + const routeRef = + this.routeBindings.get(routeRefOrExternalRouteRef) ?? + (routeRefOrExternalRouteRef as RouteRef); + + const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; + + const lastPath = this.routePaths.get(routeRef); + if (!lastPath) { + throw new Error(`No path for ${routeRef}`); + } + const targetRefStack = Array(); + let matchIndex = -1; + + for ( + let currentRouteRef: AnyRouteRef | undefined = routeRef; + currentRouteRef; + currentRouteRef = this.routeParents.get(currentRouteRef) + ) { + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!), + ); + if (matchIndex !== -1) { + break; + } + + targetRefStack.unshift(currentRouteRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (targetRefStack.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the called would have no way of knowing + // what parameters those are. + const prefixPath = targetRefStack + .slice(0, -1) + .map(ref => { + const path = this.routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${routeRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }) + .join('/') + .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s + + const routeFunc: RouteFunc = (...[params]) => { + return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; + }; + return routeFunc; + } +} + +const RoutingContext = createContext(undefined); + +export function useRouteRef( + routeRef: RouteRef | ExternalRouteRef, +): RouteFunc { + const sourceLocation = useLocation(); + const resolver = useContext(RoutingContext); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!routeFunc) { + throw new Error('No route resolver found in context'); + } + + return routeFunc; +} + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + routeBindings: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + routeBindings, + children, +}: ProviderProps) => { + const resolver = new RouteResolver( + routePaths, + routeParents, + routeObjects, + routeBindings, + ); + return ( + + {children} + + ); +}; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 29de34ec42..9564b3b225 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,13 @@ * limitations under the License. */ -export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; -export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; +export type { + RouteRef, + RouteRefConfig, + AbsoluteRouteRef, + ConcreteRoute, + MutableRouteRef, +} from './types'; +export { FlatRoutes } from './FlatRoutes'; export { createRouteRef } from './RouteRef'; +export { useRouteRef } from './hooks'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 162ac74bde..99a20c31e9 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,26 +16,48 @@ import { IconComponent } from '../icons'; -export const resolveRoute = Symbol('resolve-route'); -export const routeReference = Symbol('route-ref'); - -export type ReferencedRoute = { - [routeReference]: unknown; -}; - -export type ConcreteRoute = ReferencedRoute & { - [resolveRoute](path: string): string; -}; - -export type RouteRef = { +// @ts-ignore, we're just embedding the Params type for usage in other places +export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead + /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ path: string; icon?: IconComponent; title: string; }; -export type RouteRefConfig = { - path: string; +export type AnyRouteRef = RouteRef; + +/** + * This type should not be used + * @deprecated + */ +export type ConcreteRoute = {}; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type AbsoluteRouteRef = RouteRef<{}>; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type MutableRouteRef = RouteRef<{}>; + +export type RouteRefConfig = { + params?: Array; + /** @deprecated Route refs no longer decide their own path */ + path?: string; icon?: IconComponent; title: string; }; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d29bc53452..0fea08e539 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,128 @@ # @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 + +- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. +- Updated dependencies [a08c32ced] +- Updated dependencies [86c3c652a] +- Updated dependencies [27f2af935] + - @backstage/core-api@0.2.8 + +## 0.4.2 + +### Patch Changes + +- 1dc445e89: Update to use new plugin extension API +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + +## 0.4.1 + +### Patch Changes + +- 8ef71ed32: Add a `` component to `@backstage/core`. +- Updated dependencies [7dd2ef7d1] + - @backstage/core-api@0.2.6 + +## 0.4.0 + +### Minor Changes + +- ff243ce96: Introducing a new optional property within `app-config.yaml` called `auth.environment` to have configurable environment value for `auth.providers` + + **Default Value:** 'development' + + **Optional Values:** 'production' | 'development' + + **Migration-steps:** + + - To override the default value, one could simply introduce the new property `environment` within the `auth` section of the `config.yaml` + - re-run the build to reflect the changed configs + +### Patch Changes + +- 2527628e1: Link `component` prop now accepts any element type. +- 1c69d4716: Fix React warning of descendant paragraph tag +- 04f26f88d: Export the `defaultConfigLoader` implementation +- Updated dependencies [b6557c098] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [d8d5a17da] +- Updated dependencies [1665ae8bb] +- Updated dependencies [e3bd9fc2f] + - @backstage/core-api@0.2.5 + - @backstage/config@0.1.2 + - @backstage/theme@0.2.2 + ## 0.3.2 ### Patch Changes @@ -21,7 +144,7 @@ ### Patch Changes - 7b37d65fd: Adds the MarkdownContent component to render and display Markdown content with the default - [GFM](https://github.github.com/gfm/) (Github flavored Markdown) dialect. + [GFM](https://github.github.com/gfm/) (GitHub Flavored Markdown) dialect. ``` @@ -59,7 +182,7 @@ - 482b6313d: Fix dense in Structured Metadata Table - 1c60f716e: Added EmptyState component -- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the default scope is configurable when overwriting the Core Api in the app. ``` diff --git a/packages/core/README.md b/packages/core/README.md index 0d0063c9fd..6d8519d46f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ This package provides the core API used by Backstage plugins and apps. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/core diff --git a/packages/core/config.d.ts b/packages/core/config.d.ts index 014e4ac934..a6f95ae71c 100644 --- a/packages/core/config.d.ts +++ b/packages/core/config.d.ts @@ -62,4 +62,17 @@ export interface Config { timezone: string; }[]; }; + + /** + * Configuration that provides information on available authentication providers configured for app + */ + auth?: { + /** + * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`. + * default value: 'development' + * optional values: 'development' | 'production' + * @visibility frontend + */ + environment?: string; + }; } diff --git a/packages/core/package.json b/packages/core/package.json index f667ffe7be..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.3.2", + "version": "0.5.0", "private": false, "publishConfig": { "access": "public", @@ -29,15 +29,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "@backstage/core-api": "^0.2.1", - "@backstage/theme": "^0.2.1", + "@backstage/config": "^0.1.2", + "@backstage/core-api": "^0.2.8", + "@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", "@types/dagre": "^0.7.44", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", + "@types/prop-types": "^15.7.3", "classnames": "^2.2.6", "clsx": "^1.1.0", "d3-selection": "^2.0.0", @@ -45,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", @@ -64,8 +65,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.3.1", - "@backstage/test-utils": "^0.1.3", + "@backstage/cli": "^0.4.7", + "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 4a1e58db42..9a8f7a36df 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import privateExports, { AppOptions, defaultSystemIcons, @@ -93,7 +93,7 @@ export function createApp(options?: AppOptions) { const DefaultNotFoundPage = () => ( ); - const DefaultBootErrorPage: FC = ({ step, error }) => { + const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => { let message = ''; if (step === 'load-config') { message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; @@ -142,6 +142,7 @@ export function createApp(options?: AppOptions) { themes, configLoader, defaultApis, + bindRoutes: options?.bindRoutes, }); app.verify(); diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index 1f18f54d2a..d044b50b7a 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -78,30 +78,42 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GoogleAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: microsoftAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - MicrosoftAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: githubAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => GithubAuth.create({ discoveryApi, oauthRequestApi, defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), }), }), createApiFactory({ @@ -109,60 +121,91 @@ export const defaultApis = [ deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OktaAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: gitlabAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - GitlabAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: auth0AuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - Auth0Auth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + Auth0Auth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oauth2ApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OAuth2.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: samlAuthApiRef, deps: { discoveryApi: discoveryApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), + factory: ({ discoveryApi, configApi }) => + SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oneloginAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => - OneLoginAuth.create({ discoveryApi, oauthRequestApi }), + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), }), createApiFactory({ api: oidcAuthApiRef, deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, }, - factory: ({ discoveryApi, oauthRequestApi }) => + factory: ({ discoveryApi, oauthRequestApi, configApi }) => OAuth2.create({ discoveryApi, oauthRequestApi, @@ -171,6 +214,7 @@ export const defaultApis = [ title: 'Your Identity Provider', icon: OAuth2Icon, }, + environment: configApi.getOptionalString('auth.environment'), }), }), ]; diff --git a/packages/core/src/api-wrappers/index.ts b/packages/core/src/api-wrappers/index.ts index b8136305b0..42c423d868 100644 --- a/packages/core/src/api-wrappers/index.ts +++ b/packages/core/src/api-wrappers/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createApp } from './createApp'; +export { createApp, defaultConfigLoader } from './createApp'; diff --git a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx index 30940f68ac..6d6646fa18 100644 --- a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx +++ b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx @@ -14,16 +14,14 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Snackbar, IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import { Alert } from '@material-ui/lab'; import { AlertMessage, useApi, alertApiRef } from '@backstage/core-api'; -type Props = {}; - // TODO: improve on this and promote to a shared component for use by all apps. -export const AlertDisplay: FC = () => { +export const AlertDisplay = () => { const [messages, setMessages] = useState>([]); const alertApi = useApi(alertApiRef); diff --git a/packages/core/src/components/Avatar/Avatar.stories.tsx b/packages/core/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000000..5ac628d72b --- /dev/null +++ b/packages/core/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Avatar } from './Avatar'; + +export default { + title: 'Data Display/Avatar', + component: Avatar, +}; + +export const Default = () => ( + +); + +export const NameFallback = () => ; + +export const Empty = () => ; + +export const CustomStyling = () => ( + +); diff --git a/plugins/sentry-backend/src/service/sentry-api.test.ts b/packages/core/src/components/Avatar/Avatar.test.tsx similarity index 66% rename from plugins/sentry-backend/src/service/sentry-api.test.ts rename to packages/core/src/components/Avatar/Avatar.test.tsx index f15692861f..da6ca8f42e 100644 --- a/plugins/sentry-backend/src/service/sentry-api.test.ts +++ b/packages/core/src/components/Avatar/Avatar.test.tsx @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getRequestHeaders } from './sentry-api'; -describe('SentryApiForwarder', () => { - it('should generate headers based on token passed in constructor', () => { - expect(getRequestHeaders('testtoken')).toEqual({ - headers: { - Authorization: `Bearer testtoken`, - }, - }); +import { render } from '@testing-library/react'; +import React from 'react'; +import { Avatar } from './Avatar'; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render(); + + expect(getByText('JD')).toBeInTheDocument(); }); }); diff --git a/packages/core/src/components/Avatar/Avatar.tsx b/packages/core/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..95aa4a8ced --- /dev/null +++ b/packages/core/src/components/Avatar/Avatar.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { CSSProperties } from 'react'; +import { + Avatar as MaterialAvatar, + createStyles, + makeStyles, + Theme, +} from '@material-ui/core'; +import { extractInitials, stringToColor } from './utils'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + avatar: { + width: '4rem', + height: '4rem', + color: '#fff', + fontWeight: theme.typography.fontWeightBold, + letterSpacing: '1px', + textTransform: 'uppercase', + }, + }), +); + +export type AvatarProps = { + displayName?: string; + picture?: string; + customStyles?: CSSProperties; +}; + +export const Avatar = ({ displayName, picture, customStyles }: AvatarProps) => { + const classes = useStyles(); + return ( + + {displayName && extractInitials(displayName)} + + ); +}; diff --git a/plugins/kubernetes/src/components/Services/index.ts b/packages/core/src/components/Avatar/index.ts similarity index 93% rename from plugins/kubernetes/src/components/Services/index.ts rename to packages/core/src/components/Avatar/index.ts index d52ebf5f14..962414634e 100644 --- a/plugins/kubernetes/src/components/Services/index.ts +++ b/packages/core/src/components/Avatar/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Services } from './Services'; +export { Avatar } from './Avatar'; diff --git a/packages/backend-common/src/service/lib/metrics.test.ts b/packages/core/src/components/Avatar/util.test.ts similarity index 51% rename from packages/backend-common/src/service/lib/metrics.test.ts rename to packages/core/src/components/Avatar/util.test.ts index 9126423b7e..94de957e8e 100644 --- a/packages/backend-common/src/service/lib/metrics.test.ts +++ b/packages/core/src/components/Avatar/util.test.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { normalizePath } from './metrics'; +import { extractInitials, stringToColor } from './utils'; -describe('normalizePath', () => { - it('should normalize /path to /path', async () => { - const path = normalizePath({ url: 'http://server/path' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /path/test to /path', async () => { - const path = normalizePath({ url: 'http://server/path/test' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /api/plugin-name/test to /api/plugin-name', async () => { - const path = normalizePath({ url: 'http://server/api/plugin-name/test' }); - - expect(path).toBe('/api/plugin-name'); +describe('stringToColor', () => { + it('extract color', async () => { + expect(stringToColor('Jenny Doe')).toEqual('#7809fa'); + }); +}); + +describe('extractInitials', () => { + it('extract initials', async () => { + expect(extractInitials('Jenny Doe')).toEqual('JD'); + }); + + it('extract single letter for short name', async () => { + expect(extractInitials('Doe')).toEqual('D'); + }); + + it('limit the initials to two letters', async () => { + expect(extractInitials('John Jonathan Doe')).toEqual('JJ'); }); }); diff --git a/packages/core/src/components/Avatar/utils.ts b/packages/core/src/components/Avatar/utils.ts new file mode 100644 index 0000000000..5990a72955 --- /dev/null +++ b/packages/core/src/components/Avatar/utils.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function stringToColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.substr(-2); + } + return color; +} + +export function extractInitials(value: string) { + return value.match(/\b\w/g)!.join('').substring(0, 2); +} diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx index cb322e5b56..9f6cdd7e7c 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useRef, useState, MouseEventHandler } from 'react'; +import React, { useRef, useState, MouseEventHandler } from 'react'; import { IconButton, makeStyles, Tooltip } from '@material-ui/core'; import PropTypes from 'prop-types'; import CopyIcon from '@material-ui/icons/FileCopy'; @@ -56,7 +56,7 @@ const defaultProps = { tooltipText: 'Text copied to clipboard', }; -export const CopyTextButton: FC = props => { +export const CopyTextButton = (props: Props) => { const { text, tooltipDelay, tooltipText } = { ...defaultProps, ...props, diff --git a/packages/core/src/components/Drawer/Drawer.stories.tsx b/packages/core/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 0000000000..b399cfed8e --- /dev/null +++ b/packages/core/src/components/Drawer/Drawer.stories.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { + Drawer, + Button, + Typography, + makeStyles, + IconButton, + createStyles, + Theme, +} from '@material-ui/core'; +import Close from '@material-ui/icons/Close'; + +export default { + title: 'Layout/Drawer', + component: Drawer, +}; + +const useDrawerStyles = makeStyles((theme: Theme) => + createStyles({ + paper: { + width: '50%', + justifyContent: 'space-between', + padding: theme.spacing(2.5), + }, + }), +); + +const useDrawerContentStyles = makeStyles((theme: Theme) => + createStyles({ + header: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + icon: { + fontSize: 20, + }, + content: { + height: '80%', + backgroundColor: '#EEEEEE', + }, + secondaryAction: { + marginLeft: theme.spacing(2.5), + }, + }), +); + +/* Example content wrapped inside the Drawer component */ +const DrawerContent = ({ + toggleDrawer, +}: { + toggleDrawer: (isOpen: boolean) => void; +}) => { + const classes = useDrawerContentStyles(); + + return ( + <> +
+ Side Panel Title + toggleDrawer(false)} + color="inherit" + > + + +
+
+
+ + +
+ + ); +}; + +/* Default drawer can toggle open or closed. + * It can be cancelled by clicking the overlay + * or pressing the esc key. + */ +export const DefaultDrawer = () => { + const [isOpen, toggleDrawer] = useState(false); + const classes = useDrawerStyles(); + + return ( + <> + + toggleDrawer(false)} + > + + + + ); +}; + +/* Persistent drawer works like the default one - + * except that the content sits on the same level + * as the main content and you can't cancel it by + * clicking the overlay or pressing the esc key. + * + * Set the Drawer variant props: 'persistent' + */ +export const PersistentDrawer = () => { + const [isOpen, toggleDrawer] = useState(false); + const classes = useDrawerStyles(); + + return ( + <> + + toggleDrawer(false)} + > + + + + ); +}; diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 1c27ba27d3..377373a06d 100644 --- a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -20,8 +20,7 @@ import { BackstageTheme } from '@backstage/theme'; import { EmptyState } from './EmptyState'; import { CodeSnippet } from '../CodeSnippet'; -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 +const COMPONENT_YAML = `apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: example @@ -31,8 +30,7 @@ metadata: spec: type: website lifecycle: production - owner: guest -`; + owner: guest`; type Props = { annotation: string; @@ -49,10 +47,10 @@ const useStyles = makeStyles(theme => ({ export const MissingAnnotationEmptyState = ({ annotation }: Props) => { const classes = useStyles(); const description = ( - + <> The {annotation} annotation is missing. You need to add the annotation to your component if you want to enable this tool. - + ); return ( { text={COMPONENT_YAML.replace('ANNOTATION', annotation)} language="yaml" showLineNumbers - highlightedNumbers={[7, 8]} + highlightedNumbers={[6, 7]} customStyle={{ background: 'inherit', fontSize: '115%' }} />
diff --git a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx index 9dc0d25681..64722c24b0 100644 --- a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx +++ b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx @@ -16,7 +16,7 @@ import { ClickAwayListener, makeStyles, Typography } from '@material-ui/core'; import React, { - FC, + PropsWithChildren, useCallback, useEffect, useLayoutEffect, @@ -93,12 +93,12 @@ type Placement = { textWidth: number; }; -export const FeatureCalloutCircular: FC = ({ +export const FeatureCalloutCircular = ({ featureId, title, description, children, -}) => { +}: PropsWithChildren) => { const { show, hide } = useShowCallout(featureId); const portalElement = usePortal('core.callout'); const wrapperRef = useRef(null); diff --git a/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx new file mode 100644 index 0000000000..a2f86d133b --- /dev/null +++ b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { IconLinkVertical, IconLinkVerticalProps } from './IconLinkVertical'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + links: { + margin: theme.spacing(2, 0), + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'min-content', + gridGap: theme.spacing(3), + }, +})); + +type Props = { + links: IconLinkVerticalProps[]; +}; + +export const HeaderIconLinkRow = ({ links }: Props) => { + const classes = useStyles(); + return ( + + ); +}; diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx similarity index 81% rename from plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx rename to packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx index dd267dde35..7a9078c3e4 100644 --- a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx +++ b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as React from 'react'; +import React from 'react'; import classnames from 'classnames'; import { makeStyles, Link } from '@material-ui/core'; import LinkIcon from '@material-ui/icons/Link'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink } from '../Link'; export type IconLinkVerticalProps = { icon?: React.ReactNode; href?: string; disabled?: boolean; - title?: string; label: string; + action?: React.ReactNode; }; -const useIconStyles = makeStyles({ +const useIconStyles = makeStyles(theme => ({ link: { display: 'grid', justifyItems: 'center', @@ -43,12 +43,16 @@ const useIconStyles = makeStyles({ fontWeight: 600, letterSpacing: 1.2, }, -}); + linkStyle: { + color: theme.palette.secondary.main, + }, +})); export function IconLinkVertical({ icon = , href = '#', disabled = false, + action, ...props }: IconLinkVerticalProps) { const classes = useIconStyles(); @@ -58,7 +62,6 @@ export function IconLinkVertical({ {icon} @@ -67,12 +70,11 @@ export function IconLinkVertical({ ); } - // Absolute links should not be using RouterLink - if (href?.startsWith('//') || href?.includes('://')) { + if (action) { return ( - + {icon} - {props.label} + {action} ); } diff --git a/packages/core/src/components/HeaderIconLinkRow/index.ts b/packages/core/src/components/HeaderIconLinkRow/index.ts new file mode 100644 index 0000000000..82fb27cfad --- /dev/null +++ b/packages/core/src/components/HeaderIconLinkRow/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { HeaderIconLinkRow } from './HeaderIconLinkRow'; diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx index 4e321dfa95..073fb960e1 100644 --- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx +++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { PropsWithChildren } from 'react'; import classNames from 'classnames'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; @@ -181,7 +181,7 @@ function useSmoothScroll( return setScrollTarget; } -export const HorizontalScrollGrid: FC = props => { +export const HorizontalScrollGrid = (props: PropsWithChildren) => { const { scrollStep = 100, scrollSpeed = 50, diff --git a/packages/core/src/components/Lifecycle/Lifecycle.tsx b/packages/core/src/components/Lifecycle/Lifecycle.tsx index 416df27a30..d388452cdc 100644 --- a/packages/core/src/components/Lifecycle/Lifecycle.tsx +++ b/packages/core/src/components/Lifecycle/Lifecycle.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import CSS from 'csstype'; import { makeStyles } from '@material-ui/core'; @@ -38,7 +38,7 @@ const useStyles = makeStyles({ }, }); -export const Lifecycle: FC = props => { +export const Lifecycle = (props: Props) => { const classes = useStyles(props); const { shorthand, alpha } = props; return shorthand ? ( diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx index 59a9604fa7..b551b0511d 100644 --- a/packages/core/src/components/Link/Link.tsx +++ b/packages/core/src/components/Link/Link.tsx @@ -14,12 +14,20 @@ * limitations under the License. */ -import React, { ComponentProps } from 'react'; -import { Link as MaterialLink } from '@material-ui/core'; -import { Link as RouterLink } from 'react-router-dom'; +import React, { ElementType } from 'react'; +import { + Link as MaterialLink, + LinkProps as MaterialLinkProps, +} from '@material-ui/core'; +import { + Link as RouterLink, + LinkProps as RouterLinkProps, +} from 'react-router-dom'; -type Props = ComponentProps & - ComponentProps & { component?: React.FC }; +type Props = MaterialLinkProps & + RouterLinkProps & { + component?: ElementType; + }; /** * Thin wrapper on top of material-ui's Link component diff --git a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index cc7d99660b..cc1df3b529 100644 --- a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -22,7 +22,7 @@ import { Typography, Theme, } from '@material-ui/core'; -import React, { FC, useState } from 'react'; +import React, { useState } from 'react'; import { PendingAuthRequest } from '@backstage/core-api'; const useItemStyles = makeStyles(theme => ({ @@ -37,7 +37,7 @@ type RowProps = { setBusy: (busy: boolean) => void; }; -const LoginRequestListItem: FC = ({ request, busy, setBusy }) => { +const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => { const classes = useItemStyles(); const [error, setError] = useState(); diff --git a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index 07078c3fa2..06b53332dc 100644 --- a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -24,7 +24,7 @@ import { Theme, Button, } from '@material-ui/core'; -import React, { FC, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import LoginRequestListItem from './LoginRequestListItem'; import { useApi, oauthRequestApiRef } from '@backstage/core-api'; @@ -41,9 +41,7 @@ const useStyles = makeStyles(theme => ({ }, })); -type OAuthRequestDialogProps = {}; - -export const OAuthRequestDialog: FC = () => { +export const OAuthRequestDialog = () => { const classes = useStyles(); const [busy, setBusy] = useState(false); const oauthRequestApi = useApi(oauthRequestApiRef); diff --git a/packages/core/src/components/Progress/Progress.tsx b/packages/core/src/components/Progress/Progress.tsx index 80f4f38cc9..aacdf8821b 100644 --- a/packages/core/src/components/Progress/Progress.tsx +++ b/packages/core/src/components/Progress/Progress.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { useState, useEffect, PropsWithChildren } from 'react'; import { LinearProgress, LinearProgressProps } from '@material-ui/core'; -export const Progress: FC = props => { +export const Progress = (props: PropsWithChildren) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { diff --git a/packages/core/src/components/ProgressBars/Gauge.stories.tsx b/packages/core/src/components/ProgressBars/Gauge.stories.tsx index 7882714529..ab9c263f05 100644 --- a/packages/core/src/components/ProgressBars/Gauge.stories.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.stories.tsx @@ -20,7 +20,7 @@ import { Gauge } from './Gauge'; const containerStyle = { width: 300 }; export default { - title: 'Feedback/Gauge', + title: 'Data Display/Gauge', component: Gauge, }; diff --git a/packages/core/src/components/ProgressBars/Gauge.tsx b/packages/core/src/components/ProgressBars/Gauge.tsx index 4c339bf8e1..ca6a3a66ab 100644 --- a/packages/core/src/components/ProgressBars/Gauge.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.tsx @@ -17,7 +17,7 @@ import { makeStyles, useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { Circle } from 'rc-progress'; -import React, { FC } from 'react'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: { @@ -77,7 +77,7 @@ export function getProgressColor( return palette.status.ok; } -export const Gauge: FC = props => { +export const Gauge = (props: Props) => { const classes = useStyles(props); const theme = useTheme(); const { value, fractional, inverse, unit, max } = { diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index 4eb4b2e075..7f281c67a5 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; import { InfoCard } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; @@ -36,7 +36,7 @@ const useStyles = makeStyles({ }, }); -export const GaugeCard: FC = props => { +export const GaugeCard = (props: Props) => { const classes = useStyles(props); const { title, subheader, progress, deepLink, variant } = props; diff --git a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx index a745d210b4..fa3c7c00f0 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx @@ -20,7 +20,7 @@ import { LinearGauge } from './LinearGauge'; const containerStyle = { width: 300 }; export default { - title: 'Feedback/LinearGauge', + title: 'Data Display/LinearGauge', component: LinearGauge, }; diff --git a/packages/core/src/components/ProgressBars/LinearGauge.tsx b/packages/core/src/components/ProgressBars/LinearGauge.tsx index a6aea59f19..9bb7b34c09 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Tooltip, useTheme } from '@material-ui/core'; // @ts-ignore import { Line } from 'rc-progress'; @@ -28,7 +28,7 @@ type Props = { value: number; }; -export const LinearGauge: FC = ({ value }) => { +export const LinearGauge = ({ value }: Props) => { const theme = useTheme(); if (isNaN(value)) { return null; diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx index 75fc1e5e62..ff65534843 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepper.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx @@ -16,9 +16,9 @@ import React, { Children, isValidElement, - FC, useState, useEffect, + PropsWithChildren, } from 'react'; import { Stepper as MuiStepper } from '@material-ui/core'; @@ -47,12 +47,12 @@ export interface StepperProps { activeStep?: number; } -export const SimpleStepper: FC = ({ +export const SimpleStepper = ({ children, elevated, onStepChange, activeStep = 0, -}) => { +}: PropsWithChildren) => { const [stepIndex, setStepIndex] = useState(activeStep); const [stepHistory, setStepHistory] = useState([0]); diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx index c8cddb375d..a49e20913c 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useContext, FC, ReactNode } from 'react'; +import React, { useContext, ReactNode, PropsWithChildren } from 'react'; import { Button, makeStyles } from '@material-ui/core'; import { StepActions } from './SimpleStepperStep'; import { VerticalStepperContext } from './SimpleStepper'; @@ -27,20 +27,33 @@ const useStyles = makeStyles(theme => ({ }, })); -export const RestartBtn: FC<{ +interface CommonBtnProps { text?: string; handleClick?: () => void; stepIndex: number; -}> = ({ text, handleClick }) => ( - -); -const NextBtn: FC<{ - text?: string; - handleClick?: () => void; +} +interface RestartBtnProps extends CommonBtnProps {} + +interface NextBtnProps extends CommonBtnProps { disabled?: boolean; last?: boolean; stepIndex: number; -}> = ({ text, handleClick, disabled, last, stepIndex }) => ( +} +interface BackBtnProps extends CommonBtnProps { + disabled?: boolean; + stepIndex: number; +} +export const RestartBtn = ({ text, handleClick }: RestartBtnProps) => ( + +); + +const NextBtn = ({ + text, + handleClick, + disabled, + last, + stepIndex, +}: NextBtnProps) => ( ); - }, -); + } + + return ( + + {content} + + ); +}); type SidebarSearchFieldProps = { onSearch: (input: string) => void; diff --git a/packages/core/src/layout/Sidebar/Page.tsx b/packages/core/src/layout/Sidebar/Page.tsx index 0c6f4a526d..25717cb053 100644 --- a/packages/core/src/layout/Sidebar/Page.tsx +++ b/packages/core/src/layout/Sidebar/Page.tsx @@ -15,7 +15,12 @@ */ import { makeStyles } from '@material-ui/core'; -import React, { createContext, FC, useEffect, useState } from 'react'; +import React, { + createContext, + PropsWithChildren, + useEffect, + useState, +} from 'react'; import { sidebarConfig } from './config'; import { BackstageTheme } from '@backstage/theme'; import { LocalStorage } from './localStorage'; @@ -44,7 +49,7 @@ export const SidebarPinStateContext = createContext( }, ); -export const SidebarPage: FC<{}> = props => { +export const SidebarPage = (props: PropsWithChildren<{}>) => { const [isPinned, setIsPinned] = useState(() => LocalStorage.getSidebarPinState(), ); diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index ebc9bf278f..83ef8fe8a8 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; @@ -32,12 +32,12 @@ export type Props = SignInPageProps & { align?: 'center' | 'left'; }; -export const SignInPage: FC = ({ +export const SignInPage = ({ onResult, providers = [], title, align = 'left', -}) => { +}: Props) => { const configApi = useApi(configApiRef); const classes = useStyles(); diff --git a/packages/core/src/layout/TabbedCard/TabbedCard.tsx b/packages/core/src/layout/TabbedCard/TabbedCard.tsx index e1967b8533..c9ae2eebc8 100644 --- a/packages/core/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core/src/layout/TabbedCard/TabbedCard.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import React, { FC, useState, ReactElement, ReactNode } from 'react'; +import React, { + useState, + ReactElement, + ReactNode, + PropsWithChildren, +} from 'react'; import { Card, CardContent, @@ -55,14 +60,14 @@ type Props = { deepLink?: BottomLinkProps; }; -const TabbedCard: FC = ({ +const TabbedCard = ({ slackChannel = '#backstage', children, title, deepLink, value, onChange, -}) => { +}: PropsWithChildren) => { const tabsClasses = useTabsStyles(); const [selectedIndex, selectIndex] = useState(0); @@ -118,7 +123,7 @@ type CardTabProps = TabProps & { children: ReactNode; }; -const CardTab: FC = ({ children, ...props }) => { +const CardTab = ({ children, ...props }: PropsWithChildren) => { const classes = useCardTabStyles(); return ; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 4526bd4733..e33d8cc633 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,566 @@ # @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 + +- 4e0e3b1bf: Add missing `yarn clean` for app. + + For users with existing Backstage installations, add the following under the `scripts` section in `packages/app/package.json`, after the "lint" entry: + + ```json + "clean": "backstage-cli clean", + ``` + + This will add the missing `yarn clean` for the generated frontend. + +- 352a6581f: Added `"start-backend"` script to root `package.json`. + + To apply this change to an existing app, add the following script to the root `package.json`: + + ```json + "start-backend": "yarn workspace backend start" + ``` + +## 0.3.0 + +### Minor Changes + +- 0101c7a16: Add search plugin to default template for CLI created apps + +### Patch Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +## 0.2.5 + +### Patch Changes + +- 2783ec018: In the techdocs-backend plugin (`packages/backend/src/plugins/techdocs.ts`), create a publisher using + + ``` + const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + instead of + + ``` + const publisher = new LocalPublish(logger, discovery); + ``` + + An instance of `publisher` can either be a local filesystem publisher or a Google Cloud Storage publisher. + + Read more about the configs here https://backstage.io/docs/features/techdocs/configuration + (You will also have to update `techdocs.storage.type` to `local` or `googleGcs`. And `techdocs.builder` to either `local` or `external`.) + +## 0.2.4 + +### Patch Changes + +- 94348441e: Add `"files": ["dist"]` to both app and backend packages. This ensures that packaged versions of these packages do not contain unnecessary files. + + To apply this change to an existing app, add the following to `packages/app/package.json` and `packages/backend/package.json`: + + ```json + "files": [ + "dist" + ] + ``` + +- cb5fc4b29: Adjust template to the latest changes in the `api-docs` plugin. + + ## Template Changes + + While updating to the latest `api-docs` plugin, the following changes are + necessary for the `create-app` template in your + `app/src/components/catalog/EntityPage.tsx`. This adds: + + - A custom entity page for API entities + - Changes the API tab to include the new `ConsumedApisCard` and + `ProvidedApisCard` that link to the API entity. + + ```diff + import { + + ApiDefinitionCard, + - Router as ApiDocsRouter, + + ConsumedApisCard, + + ProvidedApisCard, + + ConsumedApisCard, + + ConsumingComponentsCard, + + ProvidedApisCard, + + ProvidingComponentsCard + } from '@backstage/plugin-api-docs'; + + ... + + +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + + + + + +); + + const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + + element={} + /> + ... + + -export const EntityPage = () => { + - const { entity } = useEntity(); + - switch (entity?.spec?.type) { + - case 'service': + - return ; + - case 'website': + - return ; + - default: + - return ; + - } + -}; + + +export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { + + switch (entity?.spec?.type) { + + case 'service': + + return ; + + case 'website': + + return ; + + default: + + return ; + + } + +}; + + + +const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + + + +const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( + + + + + + + + + + + +); + + + +const ApiEntityPage = ({ entity }: { entity: Entity }) => ( + + + + } + + /> + + } + + /> + + + +); + + + +export const EntityPage = () => { + + const { entity } = useEntity(); + + + + switch (entity?.kind?.toLowerCase()) { + + case 'component': + + return ; + + case 'api': + + return ; + + default: + + return ; + + } + +}; + ``` + +- 1e22f8e0b: Unify `dockerode` library and type dependency versions + +## 0.2.3 + +### Patch Changes + +- 68fdc3a9f: Optimized the `yarn install` step in the backend `Dockerfile`. + + To apply these changes to an existing app, make the following changes to `packages/backend/Dockerfile`: + + Replace the `RUN yarn install ...` line with the following: + + ```bash + RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" + ``` + +- 4a655c89d: Removed `"resolutions"` entry for `esbuild` in the root `package.json` in order to use the version specified by `@backstage/cli`. + + To apply this change to an existing app, remove the following from your root `package.json`: + + ```json + "resolutions": { + "esbuild": "0.6.3" + }, + ``` + +- ea475893d: Add [API docs plugin](https://github.com/backstage/backstage/tree/master/plugins/api-docs) to new apps being created through the CLI. + ## 0.2.2 ### Patch Changes @@ -186,7 +747,7 @@ --config ../../app-config.yaml --config ../../app-config.development.yaml ``` -- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens. +- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for GitHub, GitLab, Azure & GitHub Enterprise access tokens. ### Detail: diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 7533110372..dafc01e365 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -1,9 +1,10 @@ # @backstage/create-app -This package provides a CLI for creating apps. +This package provides a CLI for creating a copy of the Backstage app. + You can use the flag `--skip-install` to skip the install. -## Installation +## Usage With `npx`: @@ -11,6 +12,12 @@ With `npx`: $ npx @backstage/create-app ``` +With a local clone of this repo, from the main `create-app/` folder, run: + +```sh +$ yarn backstage-create-app +``` + ## Documentation - [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) diff --git a/packages/create-app/bin/backstage-create-app b/packages/create-app/bin/backstage-create-app index 467a0cae8f..902f341bfb 100755 --- a/packages/create-app/bin/backstage-create-app +++ b/packages/create-app/bin/backstage-create-app @@ -24,6 +24,8 @@ const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { require('..'); } else { + // Only used for development, so should be a devDependency + // eslint-disable-next-line import/no-extraneous-dependencies require('ts-node').register({ transpileOnly: true, project: path.resolve(__dirname, '../../../tsconfig.json'), diff --git a/packages/create-app/package.json b/packages/create-app/package.json index adb9d30465..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.2.2", + "version": "0.3.6", "private": false, "publishConfig": { "access": "public" @@ -37,38 +37,45 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { - "@backstage/backend-common": "^0.3.2", - "@backstage/catalog-model": "^0.3.1", - "@backstage/cli": "^0.3.2", - "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.2", - "@backstage/plugin-api-docs": "^0.3.0", - "@backstage/plugin-app-backend": "^0.3.1", - "@backstage/plugin-auth-backend": "^0.2.4", - "@backstage/plugin-catalog": "^0.2.4", - "@backstage/plugin-catalog-backend": "^0.2.3", - "@backstage/plugin-circleci": "^0.2.2", - "@backstage/plugin-explore": "^0.2.1", - "@backstage/plugin-github-actions": "^0.2.2", - "@backstage/plugin-lighthouse": "^0.2.3", - "@backstage/plugin-proxy-backend": "^0.2.1", - "@backstage/plugin-register-component": "^0.2.2", - "@backstage/plugin-rollbar-backend": "^0.1.4", - "@backstage/plugin-scaffolder": "^0.3.1", - "@backstage/plugin-scaffolder-backend": "^0.3.2", - "@backstage/plugin-tech-radar": "^0.3.0", - "@backstage/plugin-techdocs": "^0.3.0", - "@backstage/plugin-techdocs-backend": "^0.3.0", - "@backstage/plugin-user-settings": "^0.2.2", - "@backstage/test-utils": "^0.1.3", - "@backstage/theme": "^0.2.1", "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "ts-node": "^8.6.2" }, + "peerDependencies": { + "@backstage/backend-common": "^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", + "ext": "ts" + }, "files": [ "bin", "dist", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 84f015509c..a703fb9305 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -14,46 +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 pluginCircleci } from '@backstage/plugin-circleci/package.json'; -import { version as pluginExplore } from '@backstage/plugin-explore/package.json'; -import { version as pluginGithubActions } from '@backstage/plugin-github-actions/package.json'; -import { version as pluginLighthouse } from '@backstage/plugin-lighthouse/package.json'; -import { version as pluginProxyBackend } from '@backstage/plugin-proxy-backend/package.json'; -import { version as pluginRegisterComponent } from '@backstage/plugin-register-component/package.json'; -import { version as pluginRollbarBackend } from '@backstage/plugin-rollbar-backend/package.json'; -import { version as pluginScaffolder } from '@backstage/plugin-scaffolder/package.json'; -import { version as pluginScaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; -import { version as pluginTechRadar } from '@backstage/plugin-tech-radar/package.json'; -import { version as pluginTechdocs } from '@backstage/plugin-techdocs/package.json'; -import { version as pluginTechdocsBackend } from '@backstage/plugin-techdocs-backend/package.json'; -import { version as pluginUserSettings } from '@backstage/plugin-user-settings/package.json'; -import { version as testUtils } from '@backstage/test-utils/package.json'; -import { version as theme } from '@backstage/theme/package.json'; +import { version as backendCommon } from '../../../backend-common/package.json'; +import { version as 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, @@ -66,15 +69,16 @@ export const packageVersions = { '@backstage/plugin-auth-backend': pluginAuthBackend, '@backstage/plugin-catalog': pluginCatalog, '@backstage/plugin-catalog-backend': pluginCatalogBackend, + '@backstage/plugin-catalog-import': pluginCatalogImport, '@backstage/plugin-circleci': pluginCircleci, '@backstage/plugin-explore': pluginExplore, '@backstage/plugin-github-actions': pluginGithubActions, '@backstage/plugin-lighthouse': pluginLighthouse, '@backstage/plugin-proxy-backend': pluginProxyBackend, - '@backstage/plugin-register-component': pluginRegisterComponent, '@backstage/plugin-rollbar-backend': pluginRollbarBackend, '@backstage/plugin-scaffolder': pluginScaffolder, '@backstage/plugin-scaffolder-backend': pluginScaffolderBackend, + '@backstage/plugin-search': pluginSearch, '@backstage/plugin-tech-radar': pluginTechRadar, '@backstage/plugin-techdocs': pluginTechdocs, '@backstage/plugin-techdocs-backend': pluginTechdocsBackend, diff --git a/packages/create-app/templates/default-app/.gitignore.hbs b/packages/create-app/templates/default-app/.gitignore.hbs index 5f5cc739f4..4adebc5adc 100644 --- a/packages/create-app/templates/default-app/.gitignore.hbs +++ b/packages/create-app/templates/default-app/.gitignore.hbs @@ -30,4 +30,7 @@ dist-types site # Local configuration files -*.local.yaml \ No newline at end of file +*.local.yaml + +# Sensitive credentials +*-credentials.yaml diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 595812dec3..d253102f71 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -57,13 +57,13 @@ proxy: changeOrigin: true techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs requestUrl: http://localhost:7000/api/techdocs + storageUrl: http://localhost:7000/api/techdocs/static/docs + builder: 'local' generators: techdocs: 'docker' - -lighthouse: - baseUrl: http://localhost:3003 + publisher: + type: 'local' auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers @@ -88,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 975a5a2ed2..3fa350fa7c 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -7,6 +7,7 @@ }, "scripts": { "start": "yarn workspace app start", + "start-backend": "yarn workspace backend start", "build": "lerna run build", "build-image": "yarn workspace backend build-image", "tsc": "tsc", @@ -44,7 +45,7 @@ }, "jest": { "transformModules": [ - "@kyma-project/asyncapi-react" + "@asyncapi/react-component" ] } } diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 3f04773e31..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 @@ -10,15 +10,15 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", - "@backstage/plugin-register-component": "^{{version '@backstage/plugin-register-component'}}", + "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}", - "@backstage/plugin-lighthouse": "^{{version '@backstage/plugin-lighthouse'}}", "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", "@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}", "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", + "@backstage/plugin-search": "^{{version '@backstage/plugin-search'}}", "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@backstage/theme": "^{{version '@backstage/theme'}}", "history": "^5.0.0", @@ -45,6 +45,7 @@ "build": "backstage-cli app:build", "test": "backstage-cli test", "lint": "backstage-cli lint", + "clean": "backstage-cli clean", "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", "cy:dev": "cypress open", @@ -61,5 +62,8 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "files": [ + "dist" + ] } diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 9449ac68a1..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 @@ -1,19 +1,22 @@ -import React, { FC } from 'react'; +import React from 'react'; import { createApp, AlertDisplay, 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 RegisterComponentRouter } from '@backstage/plugin-register-component'; +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'; @@ -32,30 +35,35 @@ const catalogRouteRef = createRouteRef({ }); -const App: FC<{}> = () => ( +const App = () => ( - + } /> - } /> + } /> } /> } + path="/catalog-import" + element={} /> + } + /> + } /> {deprecatedAppRoutes} - + diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx index d2b1bf1080..2fb767465b 100644 --- a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -26,7 +26,7 @@ const useStyles = makeStyles({ fill: '#7df3e1', }, }); -const LogoFull: FC<{}> = () => { +const LogoFull = () => { const classes = useStyles(); return ( diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx index d70be3dd32..507e47ddb9 100644 --- a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles({ @@ -27,7 +27,7 @@ const useStyles = makeStyles({ }, }); -const LogoIcon: FC<{}> = () => { +const LogoIcon = () => { const classes = useStyles(); return ( diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index 7d65b3261a..b5e384f7a9 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -13,26 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Router as GitHubActionsRouter, - isPluginApplicableToEntity as isGitHubActionsAvailable, -} from '@backstage/plugin-github-actions'; -import { - Router as CircleCIRouter, - isPluginApplicableToEntity as isCircleCIAvailable, -} from '@backstage/plugin-circleci'; -import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; -import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; - -import React from 'react'; -import { - EntityPageLayout, - useEntity, - AboutCard, -} from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { Grid } from '@material-ui/core'; +import { ApiEntity, Entity } from '@backstage/catalog-model'; import { WarningPanel } from '@backstage/core'; +import { + ApiDefinitionCard, + ConsumedApisCard, + ConsumingComponentsCard, + ProvidedApisCard, + ProvidingComponentsCard +} from '@backstage/plugin-api-docs'; +import { + AboutCard, EntityPageLayout, + useEntity +} from '@backstage/plugin-catalog'; +import { + isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter +} from '@backstage/plugin-circleci'; +import { + isPluginApplicableToEntity as isGitHubActionsAvailable, Router as GitHubActionsRouter +} from '@backstage/plugin-github-actions'; +import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { Grid } from '@material-ui/core'; +import React from 'react'; + const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -60,6 +63,17 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); +const ComponentApisContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + +); + const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ( } + element={} /> ( ); -export const EntityPage = () => { - const { entity } = useEntity(); +export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { switch (entity?.spec?.type) { case 'service': return ; @@ -131,3 +144,55 @@ export const EntityPage = () => { return ; } }; + +const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + + + + + + + + + +); + +const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( + + + + + +); + +const ApiEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLowerCase()) { + case 'component': + return ; + case 'api': + return ; + default: + return ; + } +}; diff --git a/packages/create-app/templates/default-app/packages/app/src/plugins.ts b/packages/create-app/templates/default-app/packages/app/src/plugins.ts index 9eabff0044..28b42d5be2 100644 --- a/packages/create-app/templates/default-app/packages/app/src/plugins.ts +++ b/packages/create-app/templates/default-app/packages/app/src/plugins.ts @@ -1,9 +1,10 @@ export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; +export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; +export { plugin as Circleci } from '@backstage/plugin-circleci'; +export { plugin as GithubActions } from '@backstage/plugin-github-actions'; export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; -export { plugin as Circleci } from '@backstage/plugin-circleci'; -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; -export { plugin as GithubActions } from '@backstage/plugin-github-actions'; +export { plugin as UserSettings } from '@backstage/plugin-user-settings'; + diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx index b5425b66b6..9f7973c387 100644 --- a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx @@ -1,10 +1,8 @@ -import React, { FC, useContext } from 'react'; +import React, { useContext } from 'react'; import HomeIcon from '@material-ui/icons/Home'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import ExtensionIcon from '@material-ui/icons/Extension'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import BuildIcon from '@material-ui/icons/BuildRounded'; -import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import { Link, makeStyles } from '@material-ui/core'; import { NavLink } from 'react-router-dom'; @@ -20,27 +18,8 @@ import { SidebarContext, SidebarSpace, } from '@backstage/core'; +import { SidebarSearch } from '@backstage/plugin-search'; -export const AppSidebar = () => ( - - - - {/* Global nav, not org-specific */} - - - - - - - - - {/* End global nav */} - - - - - -); const useSidebarLogoStyles = makeStyles({ root: { @@ -57,7 +36,7 @@ const useSidebarLogoStyles = makeStyles({ }, }); -const SidebarLogo: FC<{}> = () => { +const SidebarLogo = () => { const classes = useSidebarLogoStyles(); const { isOpen } = useContext(SidebarContext); @@ -74,3 +53,23 @@ const SidebarLogo: FC<{}> = () => {
); }; + +export const AppSidebar = () => ( + + + + + {/* Global nav, not org-specific */} + + + + + + + {/* End global nav */} + + + + + +); diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 07d1b0be58..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,9 +27,9 @@ "@backstage/plugin-proxy-backend": "^{{version '@backstage/plugin-proxy-backend'}}", "@backstage/plugin-scaffolder-backend": "^{{version '@backstage/plugin-scaffolder-backend'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", - "@octokit/rest": "^18.0.0", - "@gitbeaker/node": "^25.2.0", - "dockerode": "^3.2.0", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", + "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -37,15 +37,17 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", {{/if}} "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@types/dockerode": "^2.5.32", + "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.47" - } + "@types/express-serve-static-core": "^4.17.5" + }, + "files": [ + "dist" + ] } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/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/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index ac4d81a8e8..5c7ec50ae6 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -1,11 +1,8 @@ import { createRouter, - DirectoryPreparer, - CommonGitPreparer, Preparers, Generators, - LocalPublish, - TechdocsGenerator, + Publisher, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -14,22 +11,28 @@ export default async function createPlugin({ logger, config, discovery, + reader, }: PluginEnvironment) { - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); - generators.register('techdocs', techdocsGenerator); + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + }); - const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(logger); - const commonGitPreparer = new CommonGitPreparer(logger); - - preparers.register('dir', directoryPreparer); - preparers.register('github', commonGitPreparer); - preparers.register('gitlab', commonGitPreparer); - - const publisher = new LocalPublish(logger, discovery); + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index fd8b11bada..c6c740bf39 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,76 @@ # @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 + +- 696b8ce74: Add new `addPage` method for use with extensions, as well as an `EntityGridItem` to easily create different test cases for entity overview cards. +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [87c0c53c2] + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + +## 0.1.6 + +### Patch Changes + +- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + + This fixes for example the following code: + + ```ts + interface MyApi { + myMethod(): void + } + + const myApiRef = createApiRef({...}); + + createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), + }) + ``` + +- e1f4e24ef: Fix @backstage/cli not being a devDependency +- Updated dependencies [2527628e1] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1c69d4716] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [4a655c89d] +- Updated dependencies [8a16e8af8] +- Updated dependencies [00670a96e] + - @backstage/cli@0.4.0 + - @backstage/test-utils@0.1.4 + ## 0.1.4 ### Patch Changes diff --git a/packages/dev-utils/README.md b/packages/dev-utils/README.md index 1aa86ba51c..4b08a5ca6d 100644 --- a/packages/dev-utils/README.md +++ b/packages/dev-utils/README.md @@ -6,7 +6,7 @@ This package provides utilities that help in developing plugins for Backstage, l ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save-dev @backstage/dev-utils diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index bf2d230ba7..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.4", + "version": "0.1.8", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.3.0", - "@backstage/core": "^0.3.1", - "@backstage/test-utils": "^0.1.3", - "@backstage/theme": "^0.2.1", + "@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", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", @@ -46,6 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { + "@backstage/cli": "^0.4.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx new file mode 100644 index 0000000000..077a8f85e8 --- /dev/null +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, GridProps, makeStyles } from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { EntityProvider } from '@backstage/plugin-catalog'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles(theme => ({ + root: ({ entity }) => ({ + position: 'relative', + + '&::before': { + content: `"${entity.metadata.name}"`, + top: -theme.typography.fontSize + 4, + display: 'block', + position: 'absolute', + color: theme.palette.textSubtle, + }, + }), +})); + +export const EntityGridItem = ({ + entity, + classes, + ...rest +}: Omit & { entity: Entity }): JSX.Element => { + const itemClasses = useStyles({ entity }); + + return ( + + + + ); +}; diff --git a/packages/dev-utils/src/components/EntityGridItem/index.ts b/packages/dev-utils/src/components/EntityGridItem/index.ts new file mode 100644 index 0000000000..e0f07b13ba --- /dev/null +++ b/packages/dev-utils/src/components/EntityGridItem/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityGridItem } from './EntityGridItem'; diff --git a/plugins/api-docs/src/catalog/index.ts b/packages/dev-utils/src/components/index.ts similarity index 94% rename from plugins/api-docs/src/catalog/index.ts rename to packages/dev-utils/src/components/index.ts index 4c177df914..34224f09fd 100644 --- a/plugins/api-docs/src/catalog/index.ts +++ b/packages/dev-utils/src/components/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { Router } from './Router'; +export * from './EntityGridItem'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index cca3470f3f..f1a6d30b4c 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { hot } from 'react-hot-loader/root'; -import React, { FC, ComponentType, ReactNode } from 'react'; +import { hot } from 'react-hot-loader'; +import React, { ComponentType, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import BookmarkIcon from '@material-ui/icons/Bookmark'; import { @@ -29,9 +29,25 @@ import { AlertDisplay, OAuthRequestDialog, AnyApiFactory, + IconComponent, + FlatRoutes, + attachComponentData, } from '@backstage/core'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; -import { Routes } from 'react-router'; +import { Outlet } from 'react-router'; + +const GatheringRoute: (props: { + path: string; + children: JSX.Element; +}) => JSX.Element = () => ; + +attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); + +type RegisterPageOptions = { + element: JSX.Element; + title?: string; + icon?: IconComponent; +}; // TODO(rugvip): export proper plugin type from core that isn't the plugin class type BackstagePlugin = ReturnType; @@ -44,6 +60,8 @@ class DevAppBuilder { private readonly plugins = new Array(); private readonly apis = new Array(); private readonly rootChildren = new Array(); + private readonly routes = new Array(); + private readonly sidebarItems = new Array(); /** * Register one or more plugins to render in the dev app @@ -56,9 +74,11 @@ class DevAppBuilder { /** * Register an API factory to add to the app */ - registerApi( - factory: ApiFactory, - ): DevAppBuilder { + registerApi< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } + >(factory: ApiFactory): DevAppBuilder { this.apis.push(factory); return this; } @@ -73,6 +93,21 @@ class DevAppBuilder { return this; } + addPage({ element, title, icon }: RegisterPageOptions): DevAppBuilder { + const path = `/page-${this.routes.length + 1}`; + this.sidebarItems.push( + , + ); + this.routes.push( + , + ); + return this; + } /** * Build a DevApp component using the resources registered so far */ @@ -88,7 +123,7 @@ class DevAppBuilder { const sidebar = this.setupSidebar(this.plugins); - const DevApp: FC<{}> = () => { + const DevApp = () => { return ( @@ -98,7 +133,10 @@ class DevAppBuilder { {sidebar} - {deprecatedAppRoutes} + + {this.routes} + {deprecatedAppRoutes} + @@ -112,7 +150,12 @@ class DevAppBuilder { * Build and render directory to #root element, with react hot loading. */ render(): void { - const DevApp = hot(this.build()); + const hotModule = + require.cache['./dev/index.tsx'] ?? + require.cache['./dev/index.ts'] ?? + module; + + const DevApp = hot(hotModule)(this.build()); const paths = this.findPluginPaths(this.plugins); @@ -164,6 +207,7 @@ class DevAppBuilder { return ( + {this.sidebarItems} {sidebarItems} ); @@ -197,7 +241,7 @@ class DevAppBuilder { // this to provide their own plugin dev wrappers. /** - * Creates a dev app for rendering one or more plugins and exposing the touchpoints of the plugin. + * Creates a dev app for rendering one or more plugins and exposing the touch points of the plugin. */ export function createDevApp() { return new DevAppBuilder(); diff --git a/packages/dev-utils/src/index.ts b/packages/dev-utils/src/index.ts index c05e67ddbc..97add5dd86 100644 --- a/packages/dev-utils/src/index.ts +++ b/packages/dev-utils/src/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './components'; export * from './devApp'; diff --git a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts index 08e493600d..d7955e5566 100644 --- a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts +++ b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts @@ -27,7 +27,7 @@ const COMMIT_SHA = execSync('git rev-parse HEAD').toString('utf8').trim(); /** - * The GithubMarkdownPrinter is a MarkdownPrinter for printing Github-flavored markdown documents. + * The GithubMarkdownPrinter is a MarkdownPrinter for printing GitHub Flavored Markdown documents. */ export default class GithubMarkdownPrinter implements MarkdownPrinter { private str: string = ''; diff --git a/packages/docgen/src/docgen/types.ts b/packages/docgen/src/docgen/types.ts index 7aff54d04c..1d1d93a6ae 100644 --- a/packages/docgen/src/docgen/types.ts +++ b/packages/docgen/src/docgen/types.ts @@ -31,7 +31,7 @@ export type TypeLink = { }; /** - * TypeInfo describes a Typescript Type. + * TypeInfo describes a TypeScript Type. */ export type TypeInfo = { id: number; diff --git a/packages/e2e-test/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 12a776d448..804fc0c63e 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,110 @@ # @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 + +- 1d1c2860f: Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl +- 4eafdec4a: Introduce readTree method for GitLab URL Reader +- 178e09323: Validate that integration config contains a valid host + +## 0.1.3 + +### Patch Changes + +- 38e24db00: Move the core url and auth logic to integration for the four major providers +- b8ecf6f48: Add the basics of cross-integration concerns +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + ## 0.1.2 ### Patch Changes diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a03a07409b..7d670ea505 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -15,31 +15,117 @@ */ export interface Config { + /** Configuration for integrations towards various external repository provider systems */ integrations?: { + /** Integration configuration for Azure */ azure?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Azure instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; + /** Integration configuration for Bitbucket */ bitbucket?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Bitbucket instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the Bitbucket API, for example https://api.bitbucket.org/2.0 + * @visibility frontend + */ apiBaseUrl?: string; + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username?: string; + /** + * Bitbucket app password used to authenticate requests. + * @visibility secret + */ + appPassword?: string; }>; + /** Integration configuration for GitHub */ github?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitHub instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the GitHub API, for example https://api.github.com + * @visibility frontend + */ apiBaseUrl?: string; - /** @visibility frontend */ + /** + * The base url for GitHub raw resources, for example https://raw.githubusercontent.com + * @visibility frontend + */ rawBaseUrl?: string; + + /** + * GitHub Apps configuration + * @visibility backend + */ + apps?: Array<{ + /** + * The numeric GitHub App ID + */ + appId: number; + /** + * The private key to use for auth against the app + * @visibility secret + */ + privateKey: string; + /** + * The secret used for webhooks + * @visibility secret + */ + webhookSecret: string; + /** + * The client ID to use + */ + clientId: string; + /** + * The client secret to use + * @visibility secret + */ + clientSecret: string; + }>; }>; + /** Integration configuration for GitLab */ gitlab?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitLab instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; }; } diff --git a/packages/integration/package.json b/packages/integration/package.json index fd4bd0bd3a..fd5268dced 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.1.2", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -11,6 +11,15 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/integration" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", @@ -20,12 +29,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1", - "git-url-parse": "^11.4.0" + "@backstage/config": "^0.1.2", + "cross-fetch": "^3.0.6", + "git-url-parse": "^11.4.3", + "@octokit/rest": "^18.0.12", + "@octokit/auth-app": "^2.10.5", + "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@types/jest": "^26.0.7" + "@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": [ "dist", 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 new file mode 100644 index 0000000000..102273a03b --- /dev/null +++ b/packages/integration/src/ScmIntegrations.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; +import { + ScmIntegration, + ScmIntegrationRegistry, + ScmIntegrationsGroup, +} from './types'; + +type IntegrationsByType = { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +}; + +export class ScmIntegrations implements ScmIntegrationRegistry { + private readonly byType: IntegrationsByType; + + static fromConfig(config: Config): ScmIntegrations { + return new ScmIntegrations({ + azure: AzureIntegration.factory({ config }), + bitbucket: BitbucketIntegration.factory({ config }), + github: GitHubIntegration.factory({ config }), + gitlab: GitLabIntegration.factory({ config }), + }); + } + + constructor(integrationsByType: IntegrationsByType) { + this.byType = integrationsByType; + } + + get azure(): ScmIntegrationsGroup { + return this.byType.azure; + } + + get bitbucket(): ScmIntegrationsGroup { + return this.byType.bitbucket; + } + + get github(): ScmIntegrationsGroup { + return this.byType.github; + } + + get gitlab(): ScmIntegrationsGroup { + return this.byType.gitlab; + } + + list(): ScmIntegration[] { + return Object.values(this.byType).flatMap( + i => i.list() as ScmIntegration[], + ); + } + + byUrl(url: string | URL): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byUrl(url)) + .find(Boolean); + } + + byHost(host: string): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byHost(host)) + .find(Boolean); + } +} diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts new file mode 100644 index 0000000000..90c098b637 --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { AzureIntegration } from './AzureIntegration'; + +describe('AzureIntegration', () => { + it('has a working factory', () => { + const integrations = AzureIntegration.factory({ + config: new ConfigReader({ + integrations: { + azure: [ + { + host: 'h.com', + token: 'token', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('dev.azure.com'); + }); + + it('returns the basics', () => { + const integration = new AzureIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('azure'); + expect(integration.title).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts new file mode 100644 index 0000000000..446e6a9480 --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; + +export class AzureIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + return basicIntegrations( + configs.map(c => new AzureIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: AzureIntegrationConfig) {} + + get type(): string { + return 'azure'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): AzureIntegrationConfig { + return this.integrationConfig; + } +} diff --git a/packages/integration/src/azure/config.test.ts b/packages/integration/src/azure/config.test.ts index 0b943f2081..eed88f09f6 100644 --- a/packages/integration/src/azure/config.test.ts +++ b/packages/integration/src/azure/config.test.ts @@ -23,7 +23,7 @@ import { describe('readAzureIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -60,9 +60,7 @@ describe('readAzureIntegrationConfig', () => { describe('readAzureIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { diff --git a/packages/integration/src/azure/config.ts b/packages/integration/src/azure/config.ts index 27b73ed707..5550e2faf5 100644 --- a/packages/integration/src/azure/config.ts +++ b/packages/integration/src/azure/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const AZURE_HOST = 'dev.azure.com'; @@ -47,6 +48,13 @@ export function readAzureIntegrationConfig( ): AzureIntegrationConfig { const host = config.getOptionalString('host') ?? AZURE_HOST; const token = config.getOptionalString('token'); + + if (!isValidHost(host)) { + throw new Error( + `Invalid Azure integration config, '${host}' is not a valid host`, + ); + } + return { host, token }; } diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts new file mode 100644 index 0000000000..438e776eef --- /dev/null +++ b/packages/integration/src/azure/core.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getAzureFileFetchUrl, + getAzureDownloadUrl, + getAzureRequestOptions, +} from './core'; + +describe('azure core', () => { + describe('getAzureRequestOptions', () => { + it('fills in the token if necessary', () => { + expect(getAzureRequestOptions({ host: '', token: '0123456789' })).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Basic OjAxMjM0NTY3ODk=', + }), + }), + ); + expect(getAzureRequestOptions({ host: '' })).toEqual( + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); + }); + + describe('getAzureFileFetchUrl', () => { + it.each([ + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }, + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }, + { + url: + 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + result: + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }, + { + url: + 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + result: + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }, + ])('should handle happy path %#', async ({ url, result }) => { + expect(getAzureFileFetchUrl(url)).toBe(result); + }); + + it.each([ + { + url: 'https://api.com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + }, + { + url: 'com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + }, + ])('should handle error path %#', ({ url, error }) => { + expect(() => getAzureFileFetchUrl(url)).toThrow(error); + }); + }); + + describe('getAzureDownloadUrl', () => { + it('do not add scopePath if no path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository', + ); + + expect(new URL(result).searchParams.get('scopePath')).toBeNull(); + }); + + it('add scopePath if a path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', + ); + expect(new URL(result).searchParams.get('scopePath')).toEqual('docs'); + }); + }); +}); diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts new file mode 100644 index 0000000000..b2878af89e --- /dev/null +++ b/packages/integration/src/azure/core.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { AzureIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents + * to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} + * + * @param url A URL pointing to a file + */ +export function getAzureFileFetchUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + const path = parsedUrl.searchParams.get('path') || ''; + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + empty !== '' || + userOrOrg === '' || + project === '' || + srcKeyword !== '_git' || + repoName === '' || + path === '' || + ref === '' + ) { + throw new Error('Wrong Azure Devops URL or Invalid file path'); + } + + // transform to api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'items', + ].join('/'); + + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url A URL pointing to a path + */ +export function getAzureDownloadUrl(url: string): string { + const { + name: repoName, + owner: project, + organization, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + // scopePath will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will only download index.md but put it in the root of the archive + const scopePath = filepath + ? `&scopePath=${encodeURIComponent(filepath)}` + : ''; + + return `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; +} + +/** + * Given a URL, return the API URL to fetch commits on the branch. + * + * @param url A URL pointing to a repository or a sub-path + */ +export function getAzureCommitsUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + // Remove the "GB" from "GBmain" for example. + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + !!empty || + !userOrOrg || + !project || + srcKeyword !== '_git' || + !repoName + ) { + throw new Error('Wrong Azure Devops URL'); + } + + // transform to commits api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'commits', + ].join('/'); + + const queryParams = []; + if (ref) { + queryParams.push(`searchCriteria.itemVersion.version=${ref}`); + } + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getAzureRequestOptions( + config: AzureIntegrationConfig, + additionalHeaders?: Record, +): RequestInit { + const headers: HeadersInit = additionalHeaders + ? { ...additionalHeaders } + : {}; + + if (config.token) { + const buffer = Buffer.from(`:${config.token}`, 'utf8'); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { headers }; +} diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index ede0c88a81..6d57437779 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -19,3 +19,9 @@ export { readAzureIntegrationConfigs, } from './config'; export type { AzureIntegrationConfig } from './config'; +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 new file mode 100644 index 0000000000..3f130a393c --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { BitbucketIntegration } from './BitbucketIntegration'; + +describe('BitbucketIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('bitbucket.org'); + }); + + it('returns the basics', () => { + const integration = new BitbucketIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('bitbucket'); + expect(integration.title).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts new file mode 100644 index 0000000000..f3e69b946a --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.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 { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketIntegrationConfig, + readBitbucketIntegrationConfigs, +} from './config'; + +export class BitbucketIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucket') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {} + + get type(): string { + return 'bitbucket'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketIntegrationConfig { + return this.integrationConfig; + } +} diff --git a/packages/integration/src/bitbucket/config.test.ts b/packages/integration/src/bitbucket/config.test.ts index 775a8b7d2d..9106afbe4f 100644 --- a/packages/integration/src/bitbucket/config.test.ts +++ b/packages/integration/src/bitbucket/config.test.ts @@ -23,7 +23,7 @@ import { describe('readBitbucketIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -83,9 +83,7 @@ describe('readBitbucketIntegrationConfig', () => { describe('readBitbucketIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index 1997a5597d..b7ad8684a9 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const BITBUCKET_HOST = 'bitbucket.org'; const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0'; @@ -75,6 +76,12 @@ export function readBitbucketIntegrationConfig( const username = config.getOptionalString('username'); const appPassword = config.getOptionalString('appPassword'); + if (!isValidHost(host)) { + throw new Error( + `Invalid Bitbucket integration config, '${host}' is not a valid host`, + ); + } + if (apiBaseUrl) { apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); } else if (host === BITBUCKET_HOST) { diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts new file mode 100644 index 0000000000..39707976fe --- /dev/null +++ b/packages/integration/src/bitbucket/core.test.ts @@ -0,0 +1,254 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; +import { BitbucketIntegrationConfig } from './config'; +import { + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; + +describe('bitbucket core', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + describe('getBitbucketRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withToken).headers as any).Authorization, + ).toEqual('Bearer A'); + expect( + (getBitbucketRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withUsernameAndPassword).headers as any) + .Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + (getBitbucketRequestOptions(withoutUsernameAndPassword).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getBitbucketFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, + ), + ).toEqual( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ); + }); + + it('happy path for Bitbucket Server', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=', + ); + }); + }); + + describe('getBitbucketDownloadUrl', () => { + it('add path param if a path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=main&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param if no path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse', + config, + ); + + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=main&prefix=backstage-mock', + ); + }); + + it('get by branch for Bitbucket Server', async () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=some-branch&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param for Bitbucket Cloud', async () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.org/backstage/mock/src/master', + config, + ); + expect(result).toEqual( + 'https://bitbucket.org/backstage/mock/get/master.zip', + ); + }); + }); + + describe('getBitbucketDefaultBranch', () => { + it('return default branch for Bitbucket Cloud', async () => { + const repoInfoResponse = { + mainbranch: { + name: 'main', + }, + }; + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoInfoResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.org/backstage/mock/src/main', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); +}); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts new file mode 100644 index 0000000000..ae61df497d --- /dev/null +++ b/packages/integration/src/bitbucket/core.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { BitbucketIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url A URL pointing to a path + * @param config The relevant provider config + */ +export async function getBitbucketDefaultBranch( + url: string, + config: BitbucketIntegrationConfig, +): Promise { + const { name: repoName, owner: project, resource } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + const branchUrl = isHosted + ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + + const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + let defaultBranch; + if (isHosted) { + const repoInfo = await response.json(); + defaultBranch = repoInfo.mainbranch.name; + } else { + const { displayId } = await response.json(); + defaultBranch = displayId; + } + if (!defaultBranch) { + throw new Error(`Failed to read default branch from ${branchUrl}`); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url A URL pointing to a path + * @param config The relevant provider config + */ +export async function getBitbucketDownloadUrl( + url: string, + config: BitbucketIntegrationConfig, +): Promise { + const { + name: repoName, + owner: project, + ref, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + + let branch = ref; + if (!branch) { + branch = await getBitbucketDefaultBranch(url, config); + } + // path will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will download the docs folder and everything below it + const path = filepath ? `&path=${encodeURIComponent(filepath)}` : ''; + const archiveUrl = isHosted + ? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.zip` + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=zip&at=${branch}&prefix=${project}-${repoName}${path}`; + + return archiveUrl; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + * to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getBitbucketFileFetchUrl( + url: string, + config: BitbucketIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (config.host === 'bitbucket.org') { + if (!ref) { + throw new Error('Invalid Bitbucket URL or file path'); + } + return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`; + } + return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getBitbucketRequestOptions( + config: BitbucketIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + } else if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index 897c00d160..9df4d3d3fe 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -19,3 +19,9 @@ export { readBitbucketIntegrationConfigs, } from './config'; export type { BitbucketIntegrationConfig } from './config'; +export { + getBitbucketDefaultBranch, + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts new file mode 100644 index 0000000000..9056517f32 --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { GitHubIntegration } from './GitHubIntegration'; + +describe('GitHubIntegration', () => { + it('has a working factory', () => { + const integrations = GitHubIntegration.factory({ + config: new ConfigReader({ + integrations: { + github: [ + { + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('github.com'); + }); + + it('returns the basics', () => { + const integration = new GitHubIntegration({ + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }); + expect(integration.type).toBe('github'); + expect(integration.title).toBe('h.com'); + expect(integration.config.host).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts new file mode 100644 index 0000000000..c103597d74 --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.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 { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + GitHubIntegrationConfig, + readGitHubIntegrationConfigs, +} from './config'; + +export class GitHubIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + return basicIntegrations( + configs.map(c => new GitHubIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: GitHubIntegrationConfig) {} + + get type(): string { + return 'github'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): GitHubIntegrationConfig { + return this.integrationConfig; + } +} 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.test.ts b/packages/integration/src/github/config.test.ts index d33cffb7be..bcac7dc82c 100644 --- a/packages/integration/src/github/config.test.ts +++ b/packages/integration/src/github/config.test.ts @@ -23,7 +23,7 @@ import { describe('readGitHubIntegrationConfig', () => { function buildConfig(provider: Partial) { - return ConfigReader.fromConfigs([{ context: '', data: provider }]); + return new ConfigReader(provider); } it('reads all values', () => { @@ -80,9 +80,7 @@ describe('readGitHubIntegrationConfigs', () => { function buildConfig( providers: Partial[], ): Config[] { - return providers.map(provider => - ConfigReader.fromConfigs([{ context: '', data: provider }]), - ); + return providers.map(provider => new ConfigReader(provider)); } it('reads all values', () => { diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index f646acc53b..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const GITHUB_HOST = 'github.com'; const GITHUB_API_BASE_URL = 'https://api.github.com'; @@ -57,6 +58,41 @@ export type GitHubIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The GitHub Apps configuration to use for requests to this provider. + * + * If no apps are specified, token or anonymous is used. + */ + apps?: GithubAppConfig[]; +}; + +/** + * The configuration parameters for authenticating a GitHub Application. + * A Github Apps configuration can be generated using the `backstage-cli create-github-app` command. + */ +export type GithubAppConfig = { + /** + * Unique app identifier, found at https://github.com/organizations/$org/settings/apps/$AppName + */ + appId: number; + /** + * The private key is used by the GitHub App integration to authenticate the app. + * A private key can be generated from the app at https://github.com/organizations/$org/settings/apps/$AppName + */ + privateKey: string; + /** + * Webhook secret can be configured at https://github.com/organizations/$org/settings/apps/$AppName + */ + webhookSecret: string; + /** + * Found at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientId: string; + /** + * Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientSecret: string; }; /** @@ -71,6 +107,19 @@ export function readGitHubIntegrationConfig( let apiBaseUrl = config.getOptionalString('apiBaseUrl'); let rawBaseUrl = config.getOptionalString('rawBaseUrl'); const token = config.getOptionalString('token'); + const apps = config.getOptionalConfigArray('apps')?.map(c => ({ + appId: c.getNumber('appId'), + clientId: c.getString('clientId'), + clientSecret: c.getString('clientSecret'), + webhookSecret: c.getString('webhookSecret'), + privateKey: c.getString('privateKey'), + })); + + if (!isValidHost(host)) { + throw new Error( + `Invalid GitHub integration config, '${host}' is not a valid host`, + ); + } if (apiBaseUrl) { apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); @@ -84,7 +133,7 @@ export function readGitHubIntegrationConfig( rawBaseUrl = GITHUB_RAW_BASE_URL; } - return { host, apiBaseUrl, rawBaseUrl, token }; + return { host, apiBaseUrl, rawBaseUrl, token, apps }; } /** diff --git a/packages/integration/src/github/core.test.ts b/packages/integration/src/github/core.test.ts new file mode 100644 index 0000000000..03235acfbb --- /dev/null +++ b/packages/integration/src/github/core.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitHubIntegrationConfig } from './config'; +import { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; + +describe('github core', () => { + describe('getGitHubRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + token: 'A', + }; + const withoutToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + }; + expect( + (getGitHubRequestOptions(withToken).headers as any).Authorization, + ).toEqual('token A'); + expect( + (getGitHubRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + }); + + describe('getGitHubFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getGitHubFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for github api', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for ghe api', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + apiBaseUrl: 'https://ghe.mycompany.net/api/v3', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for github raw', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + rawBaseUrl: 'https://raw.githubusercontent.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', + ); + }); + + it('happy path for ghe raw', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + rawBaseUrl: 'https://ghe.mycompany.net/raw', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'); + }); + }); +}); diff --git a/packages/integration/src/github/core.ts b/packages/integration/src/github/core.ts new file mode 100644 index 0000000000..239692e962 --- /dev/null +++ b/packages/integration/src/github/core.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { GitHubIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://github.com/a/b/blob/branchname/path/to/c.yaml + * to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname + * or: https://raw.githubusercontent.com/a/b/branchname/c.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getGitHubFileFetchUrl( + url: string, + config: GitHubIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + !ref || + (filepathtype !== 'blob' && filepathtype !== 'raw') + ) { + throw new Error('Invalid GitHub URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + if (chooseEndpoint(config) === 'api') { + return `${config.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`; + } + return `${config.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitHubRequestOptions( + config: GitHubIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (chooseEndpoint(config) === 'api') { + headers.Accept = 'application/vnd.github.v3.raw'; + } + if (config.token) { + headers.Authorization = `token ${config.token}`; + } + + return { headers }; +} + +export function chooseEndpoint(config: GitHubIntegrationConfig): 'api' | 'raw' { + if (config.apiBaseUrl && (config.token || !config.rawBaseUrl)) { + return 'api'; + } + return 'raw'; +} diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 2099dd42e3..6491e8dcc5 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -19,3 +19,5 @@ export { readGitHubIntegrationConfigs, } 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 new file mode 100644 index 0000000000..8814e33302 --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { GitLabIntegration } from './GitLabIntegration'; + +describe('GitLabIntegration', () => { + it('has a working factory', () => { + const integrations = GitLabIntegration.factory({ + config: new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'h.com', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('gitlab.com'); + }); + + it('returns the basics', () => { + const integration = new GitLabIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('gitlab'); + expect(integration.title).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts new file mode 100644 index 0000000000..d939917366 --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.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 { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + GitLabIntegrationConfig, + readGitLabIntegrationConfigs, +} from './config'; + +export class GitLabIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + return basicIntegrations( + configs.map(c => new GitLabIntegration(c)), + i => i.config.host, + ); + }; + + constructor(private readonly integrationConfig: GitLabIntegrationConfig) {} + + get type(): string { + return 'gitlab'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): GitLabIntegrationConfig { + return this.integrationConfig; + } +} diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index 9998a530c6..303b4bd270 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -23,7 +23,7 @@ import { describe('readGitLabIntegrationConfig', () => { function buildConfig(data: Partial): Config { - return ConfigReader.fromConfigs([{ context: '', data }]); + return new ConfigReader(data); } it('reads all values', () => { @@ -31,17 +31,36 @@ 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', }); }); it('inserts the defaults if missing', () => { const output = readGitLabIntegrationConfig(buildConfig({})); - expect(output).toEqual({ host: 'gitlab.com' }); + expect(output).toEqual({ + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', + }); + }); + + it('injects the correct GitLab API base URL when missing', () => { + const output = readGitLabIntegrationConfig( + buildConfig({ host: 'gitlab.com' }), + ); + + expect(output).toEqual({ + host: 'gitlab.com', + baseUrl: 'https://gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + }); }); it('rejects funky configs', () => { @@ -60,9 +79,7 @@ describe('readGitLabIntegrationConfig', () => { describe('readGitLabIntegrationConfigs', () => { function buildConfig(data: Partial[]): Config[] { - return data.map(item => - ConfigReader.fromConfigs([{ context: '', data: item }]), - ); + return data.map(item => new ConfigReader(item)); } it('reads all values', () => { @@ -77,6 +94,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toContainEqual({ host: 'a.com', token: 't', + baseUrl: 'https://a.com', }); }); @@ -85,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 97c948c999..47269b4c32 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -15,8 +15,10 @@ */ import { Config } from '@backstage/config'; +import { isValidHost } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; +const GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'; /** * The configuration parameters for a single GitLab integration. @@ -27,12 +29,31 @@ export type GitLabIntegrationConfig = { */ host: string; + /** + * The base URL of the API of this provider, e.g. "https://gitlab.com/api/v4", + * with no trailing slash. + * + * May be omitted specifically for GitLab; then it will be deduced. + * + * The API will always be preferred if both its base URL and a token are + * present. + */ + apiBaseUrl?: string; + /** * The authorization token to use for requests this provider. * * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The baseUrl of this provider, e.g "https://gitlab.com", + * which is passed into the gitlab client. + * + * If no baseUrl is provided, it will default to https://${host} + */ + baseUrl?: string; }; /** @@ -44,8 +65,23 @@ export function readGitLabIntegrationConfig( config: Config, ): GitLabIntegrationConfig { const host = config.getOptionalString('host') ?? GITLAB_HOST; + let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); - return { host, token }; + const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`; + + if (!isValidHost(host)) { + throw new Error( + `Invalid GitLab integration config, '${host}' is not a valid host`, + ); + } + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (host === GITLAB_HOST) { + apiBaseUrl = GITLAB_API_BASE_URL; + } + + return { host, token, apiBaseUrl, baseUrl }; } /** @@ -63,7 +99,7 @@ export function readGitLabIntegrationConfigs( // As a convenience we always make sure there's at least an unauthenticated // reader for public gitlab repos. if (!result.some(c => c.host === GITLAB_HOST)) { - result.push({ host: GITLAB_HOST }); + result.push({ host: GITLAB_HOST, apiBaseUrl: GITLAB_API_BASE_URL }); } return result; diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts new file mode 100644 index 0000000000..43fea72e0b --- /dev/null +++ b/packages/integration/src/gitlab/core.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { GitLabIntegrationConfig } from './config'; +import { getGitLabFileFetchUrl } from './core'; + +const worker = setupServer(); + +describe('gitlab core', () => { + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + afterEach(() => worker.resetHandlers()); + + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + ); + }); + + const configWithToken: GitLabIntegrationConfig = { + host: 'g.com', + token: '0123456789', + }; + + const configWithNoToken: GitLabIntegrationConfig = { + host: 'g.com', + }; + + describe('getGitLabFileFetchUrl', () => { + it.each([ + // Project URLs + { + config: configWithNoToken, + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithToken, + url: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + result: + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithNoToken, + url: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + // Raw URLs + { + config: configWithNoToken, + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + result: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }, + ])('should handle happy path %#', async ({ config, url, result }) => { + await expect(getGitLabFileFetchUrl(url, config)).resolves.toBe(result); + }); + }); +}); diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts new file mode 100644 index 0000000000..29dcc60bac --- /dev/null +++ b/packages/integration/src/gitlab/core.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitLabIntegrationConfig } from './config'; +import fetch from 'cross-fetch'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://gitlab.example.com/a/b/blob/master/c.yaml + * to: https://gitlab.example.com/a/b/raw/master/c.yaml + * -or- + * from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + * to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export async function getGitLabFileFetchUrl( + url: string, + config: GitLabIntegrationConfig, +): Promise { + // TODO(Rugvip): From the old GitlabReaderProcessor; used + // the existence of /-/blob/ to switch the logic. Don't know if this + // makes sense and it might require some more work. + if (url.includes('/-/blob/')) { + const projectID = await getProjectId(url, config); + return buildProjectUrl(url, projectID).toString(); + } + return buildRawUrl(url).toString(); +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitLabRequestOptions( + config: GitLabIntegrationConfig, +): RequestInit { + const { token = '' } = config; + return { + headers: { + 'PRIVATE-TOKEN': token, + }, + }; +} + +// Converts +// from: https://gitlab.example.com/a/b/blob/master/c.yaml +// to: https://gitlab.example.com/a/b/raw/master/c.yaml +export function buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + repoName, + blobKeyword, + ...restOfPath + ] = url.pathname.split('/'); + + if ( + empty !== '' || + userOrOrg === '' || + repoName === '' || + blobKeyword !== 'blob' || + !restOfPath.join('/').match(/\.yaml$/) + ) { + throw new Error('Wrong GitLab URL'); + } + + // Replace 'blob' with 'raw' + url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join('/'); + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Converts +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch +export function buildProjectUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndFilePath = url.pathname.split('/-/blob/')[1]; + const [branch, ...filePath] = branchAndFilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(filePath.join('/')), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Convert +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: The project ID that corresponds to the URL +export async function getProjectId( + target: string, + config: GitLabIntegrationConfig, +): Promise { + const url = new URL(target); + + if (!url.pathname.includes('/-/blob/')) { + throw new Error('Please provide full path to yaml file from GitLab'); + } + + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Convert + // to: https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + getGitLabRequestOptions(config), + ); + const projectIDJson = await response.json(); + const projectID = Number(projectIDJson.id); + + return projectID; + } catch (e) { + throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); + } +} diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 0801914fd4..8dc4e90764 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -19,3 +19,4 @@ export { readGitLabIntegrationConfigs, } from './config'; export type { GitLabIntegrationConfig } from './config'; +export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts new file mode 100644 index 0000000000..9f4c531ba6 --- /dev/null +++ b/packages/integration/src/helpers.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isValidHost } from './helpers'; + +describe('isValidHost', () => { + it.each([ + ['example.com', true], + ['foo', true], + ['foo:1', true], + ['foo:10000', true], + ['foo.bar', true], + ['foo.bar.baz', true], + ['1.2.3.4', true], + ['[::]', true], + ['[::1]', true], + ['[1:2:3:4:5:6:7:8]', true], + ['1.2.3.4.5.6.7.8', true], + ['https://example.com', false], + ['foo:100000', false], + ['FOO', false], + ['Foo', false], + ['foo/bar', false], + ['//foo', false], + ['foo:bar', false], + ['foo?', false], + ['foo?bar', false], + ['foo#', false], + ['foo#bar', false], + ['::', false], + ['::1', false], + ['1:2:3:4:5:6:7:8', false], + ['???????', false], + ['€&()=)&(', false], + ['höst', false], + ['πœπœfiπœ', false], + ])('Should check whether %s is a valid host', (str, expected) => { + expect(isValidHost(str)).toBe(expected); + }); +}); diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts new file mode 100644 index 0000000000..cc1c59a238 --- /dev/null +++ b/packages/integration/src/helpers.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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/index.ts b/packages/integration/src/index.ts index bfed81824f..fdcd7da676 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,3 +18,5 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; +export { ScmIntegrations } from './ScmIntegrations'; +export type { ScmIntegration, ScmIntegrationRegistry } from './types'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts new file mode 100644 index 0000000000..d8fb7a14e2 --- /dev/null +++ b/packages/integration/src/types.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; + +/** + * Encapsulates a single SCM integration. + */ +export interface ScmIntegration { + /** + * The type of integration, e.g. "github". + */ + type: string; + + /** + * A human readable title for the integration, that can be shown to users to + * differentiate between different integrations. + */ + title: string; +} + +/** + * Encapsulates several integrations, that are all of the same type. + */ +export interface ScmIntegrationsGroup { + /** + * Lists all registered integrations of this type. + */ + list(): T[]; + + /** + * Fetches an integration of this type by URL. + * + * @param url A URL that matches a registered integration of this type + */ + byUrl(url: string | URL): T | undefined; + + /** + * Fetches an integration of this type by host name. + * + * @param url A host name that matches a registered integration of this type + */ + byHost(host: string): T | undefined; +} + +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +} + +export type ScmIntegrationsFactory = (options: { + config: Config; +}) => ScmIntegrationsGroup; diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 878256ff5c..128ccfc67a 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -26,7 +26,7 @@ import { const builder = ApiRegistry.builder(); -builder.add(configApiRef, ConfigReader.fromConfigs([])); +builder.add(configApiRef, new ConfigReader({})); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 136b3945da..77bafdd231 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -22,7 +22,7 @@ module.exports = { // Remove the default babel-loader for js files, we're using sucrase instead const [jsLoader] = config.module.rules.splice(0, 1); - if (jsLoader.use[0].loader !== 'babel-loader') { + if (!jsLoader.use[0].loader.includes('babel-loader')) { throw new Error( `Unexpected loader removed from storybook config, ${jsLoader.use[0].loader}`, ); diff --git a/packages/storybook/README.md b/packages/storybook/README.md index 38708afe4b..3e88b54db0 100644 --- a/packages/storybook/README.md +++ b/packages/storybook/README.md @@ -1,7 +1,7 @@ # storybook -This package provides a storybook build for Backstage. See [http://backstage.io/storybook](http://http://backstage.io/storybook) +This package provides a Storybook build for Backstage. See https://backstage.io/storybook/. ## Why is this not part of `@backstage/core`? -This separate storybook package exists because of dependency conflicts with `@backstage/cli`. It uses nohoist to avoid the conflicts, and since you can only use that in private packages it has to be separated out of `@backstage/core`. +This separate storybook package exists because of dependency conflicts with `@backstage/cli`. It uses `nohoist` to avoid the conflicts, and since you can only use that in private packages it has to be separated out of `@backstage/core`. diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f143596a9a..01aabac0de 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -17,11 +17,11 @@ "@backstage/theme": "^0.2.0" }, "devDependencies": { - "@storybook/addon-actions": "^6.0.21", - "@storybook/addon-links": "^6.0.21", - "@storybook/addon-storysource": "^6.0.21", - "@storybook/addons": "^6.0.21", - "@storybook/react": "^6.0.21", - "storybook-dark-mode": "^1.0.2" + "@storybook/addon-actions": "^6.1.11", + "@storybook/addon-links": "^6.1.11", + "@storybook/addon-storysource": "^6.1.11", + "@storybook/addons": "^6.1.11", + "@storybook/react": "^6.1.11", + "storybook-dark-mode": "^1.0.3" } } diff --git a/plugins/sentry-backend/.eslintrc.js b/packages/techdocs-common/.eslintrc.js similarity index 100% rename from plugins/sentry-backend/.eslintrc.js rename to packages/techdocs-common/.eslintrc.js diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md new file mode 100644 index 0000000000..7bff39d3c4 --- /dev/null +++ b/packages/techdocs-common/CHANGELOG.md @@ -0,0 +1,189 @@ +# @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 + +- 8804e8981: Using @backstage/integration package for GitHub/GitLab/Azure tokens and request options. + + Most probably you do not have to make any changes in the app because of this change. + However, if you are using the `DirectoryPreparer` or `CommonGitPreparer` exported by + `@backstage/techdocs-common` package, you now need to add pass in a `config` (from `@backstage/config`) + instance as argument. + + ``` + + const directoryPreparer = new DirectoryPreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + + const directoryPreparer = new DirectoryPreparer(config, logger); + const commonGitPreparer = new CommonGitPreparer(config, logger); + ``` + +## 0.3.0 + +### Minor Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +## 0.2.0 + +### Minor Changes + +- dae4f3983: _Breaking changes_ + + 1. Added option to use Google Cloud Storage as a choice to store the static generated files for TechDocs. + It can be configured using `techdocs.publisher.type` option in `app-config.yaml`. + Step-by-step guide to configure GCS is available here https://backstage.io/docs/features/techdocs/using-cloud-storage + Set `techdocs.publisher.type` to `'local'` if you want to continue using local filesystem to store TechDocs files. + + 2. `techdocs.builder` is now required and can be set to `'local'` or `'external'`. (Set it to `'local'` for now, since CI/CD build + workflow for TechDocs will be available soon (in few weeks)). + If builder is set to 'local' and you open a TechDocs page, `techdocs-backend` will try to generate the docs, publish to storage and + show the generated docs afterwords. + If builder is set to `'external'`, `techdocs-backend` will only fetch the docs and will NOT try to generate and publish. In this case of `'external'`, + we assume that docs are being built in the CI/CD pipeline of the repository. + TechDocs will not assume a default value for `techdocs.builder`. It is better to explicitly define it in the `app-config.yaml`. + + 3. When configuring TechDocs in your backend, there is a difference in how a new publisher is created. + + ``` + --- const publisher = new LocalPublish(logger, discovery); + +++ const publisher = Publisher.fromConfig(config, logger, discovery); + ``` + + Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. + + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + + 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs + to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - + app should only import `@backstage/plugin-techdocs` and `@backstage/plugin-techdocs-backend`. + + _Patch changes_ + + 1. See all of TechDocs config options and its documentation https://backstage.io/docs/features/techdocs/configuration + + 2. Logic about serving static files and metadata retrieval have been abstracted away from the router in `techdocs-backend` to the instance of publisher. + + 3. Removed Material UI Spinner from TechDocs header. Spinners cause unnecessary UX distraction. + Case 1 (when docs are built and are to be served): Spinners appear for a split second before the name of site shows up. This unnecessarily distracts eyes because spinners increase the size of the Header. A dot (.) would do fine. Definitely more can be done. + Case 2 (when docs are being generated): There is already a linear progress bar (which is recommended in Storybook). + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 diff --git a/packages/techdocs-common/README.md b/packages/techdocs-common/README.md new file mode 100644 index 0000000000..e4889d6f79 --- /dev/null +++ b/packages/techdocs-common/README.md @@ -0,0 +1,49 @@ +# @backstage/techdocs-common + +Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli + +This package is used by `techdocs-backend` to serve docs from different types of publishers (Google GCS, Local, etc.). +It is also used to build docs and publish them to storage, by both `techdocs-backend` and `techdocs-cli`. + +## Usage + +Create a preparer instance from the [preparers available](/packages/techdocs-common/src/stages/prepare) at which takes an Entity instance. +Run the [docs generator](/packages/techdocs-common/src/stages/generate) on the prepared directory. +Publish the generated directory files to a [storage](/packages/techdocs-common/src/stages/publish) of your choice. + +Example: + +```js +async () => { + const preparedDir = await preparer.prepare(entity); + + const parsedLocationAnnotation = getLocationForEntity(entity); + const { resultDir } = await generator.run({ + directory: preparedDir, + dockerClient: dockerClient, + parsedLocationAnnotation, + }); + + await publisher.publish({ + entity: entity, + directory: resultDir, + }); +}; +``` + +## Features + +Currently the build process is split up in these three stages. + +- Preparers +- Generators +- Publishers + +Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/backstage/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. + +Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. + +Publishers gets a folder path from the generator and publish it to your storage solution. Read documentation to know more about configuring storage solutions. +http://backstage.io/docs/features/techdocs/configuration + +Any of these can be extended. We want to extend our support to most of the storage providers (Publishers) and source code host providers (Preparers). diff --git a/packages/techdocs-common/__mocks__/@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 new file mode 100644 index 0000000000..b84018c089 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.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. + */ +type storageOptions = { + keyFilename?: string; +}; + +class Bucket { + private readonly bucketName; + + constructor(bucketName: string) { + this.bucketName = bucketName; + } + + getMetadata() { + return new Promise(resolve => { + resolve(''); + }); + } + + upload(source: string, { destination }) { + return new Promise(resolve => { + resolve({ source, destination }); + }); + } +} + +export class Storage { + private readonly keyFilename; + + constructor(options: storageOptions) { + this.keyFilename = options.keyFilename; + } + + bucket(bucketName) { + return new Bucket(bucketName); + } +} diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json new file mode 100644 index 0000000000..93825511cb --- /dev/null +++ b/packages/techdocs-common/package.json @@ -0,0 +1,75 @@ +{ + "name": "@backstage/techdocs-common", + "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", + "version": "0.3.5", + "main": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-common" + }, + "keywords": [ + "techdocs", + "backstage" + ], + "license": "Apache-2.0", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@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.3.0", + "@google-cloud/storage": "^5.6.0", + "@types/dockerode": "^3.2.1", + "@types/express": "^4.17.6", + "cross-fetch": "^3.0.6", + "dockerode": "^3.2.1", + "express": "^4.17.1", + "fs-extra": "^9.0.1", + "git-url-parse": "^11.4.3", + "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": { + "@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", + "@types/mime-types": "^2.1.0", + "@types/mock-fs": "^4.13.0", + "@types/recursive-readdir": "^2.2.0" + }, + "jest": { + "roots": [ + ".." + ] + } +} diff --git a/plugins/techdocs-backend/src/default-branch.ts b/packages/techdocs-common/src/default-branch.ts similarity index 73% rename from plugins/techdocs-backend/src/default-branch.ts rename to packages/techdocs-common/src/default-branch.ts index 1090fcb3d3..c0ec63af10 100644 --- a/plugins/techdocs-backend/src/default-branch.ts +++ b/packages/techdocs-common/src/default-branch.ts @@ -16,13 +16,17 @@ import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; import { Config } from '@backstage/config'; -import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; import { - getAzureHostToken, + getGitHubRequestOptions, + getGitLabRequestOptions, + getAzureRequestOptions, +} from '@backstage/integration'; +import { getGitHost, - getGithubHostToken, - getGitlabHostToken, getGitRepoType, + getGitHubIntegrationConfig, + getGitLabIntegrationConfig, + getAzureIntegrationConfig, } from './git-auth'; interface IGitlabBranch { @@ -50,19 +54,19 @@ interface IGitlabBranch { } function getGithubApiUrl(config: Config, url: string): URL { - const { protocol, owner, name } = parseGitUrl(url); + const { resource, owner, name } = parseGitUrl(url); const providerConfigs = config.getOptionalConfigArray('integrations.github') ?? []; - // TODO: Maybe we need to filter by host in the array, not sure about GHE - const targetProviderConfig = providerConfigs[0]; + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === resource, + ); const apiBaseUrl = - targetProviderConfig?.getOptionalString('integrations.github.apiBaseUrl') ?? - 'api.github.com'; + hostConfig[0]?.getOptionalString('apiBaseUrl') ?? 'https://api.github.com'; const apiRepos = 'repos'; - return new URL(`${protocol}://${apiBaseUrl}/${apiRepos}/${owner}/${name}`); + return new URL(`${apiBaseUrl}/${apiRepos}/${owner}/${name}`); } function getGitlabApiUrl(url: string): URL { @@ -86,62 +90,15 @@ function getAzureApiUrl(url: string): URL { ); } -function getGithubRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; - - const token = getGithubHostToken(config, host); - - if (token) { - headers.Authorization = `token ${token}`; - } - - return { - headers, - }; -} - -function getGitlabRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = { - 'PRIVATE-TOKEN': '', - }; - - const token = getGitlabHostToken(config, host); - if (token) { - headers['PRIVATE-TOKEN'] = token; - } - - return { - headers, - }; -} - -function getAzureRequestOptions(config: Config, host: string): RequestInit { - const headers: HeadersInit = {}; - - const token = getAzureHostToken(config, host); - - if (token !== '') { - headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString( - 'base64', - )}`; - } - - const requestOptions: RequestInit = { - headers, - }; - - return requestOptions; -} - async function getGithubDefaultBranch( repositoryUrl: string, config: Config, ): Promise { const path = getGithubApiUrl(config, repositoryUrl).toString(); const host = getGitHost(repositoryUrl); - const options = getGithubRequestOptions(config, host); + + const integrationConfig = getGitHubIntegrationConfig(config, host); + const options = getGitHubRequestOptions(integrationConfig); try { const raw = await fetch(path, options); @@ -169,9 +126,10 @@ async function getGitlabDefaultBranch( config: Config, ): Promise { const path = getGitlabApiUrl(repositoryUrl).toString(); + const host = getGitHost(repositoryUrl); - const gitlabHost = getGitHost(repositoryUrl); - const options = getGitlabRequestOptions(config, gitlabHost); + const integrationConfig = getGitLabIntegrationConfig(config, host); + const options = getGitLabRequestOptions(integrationConfig); try { const raw = await fetch(path, options); @@ -203,7 +161,9 @@ async function getAzureDefaultBranch( ): Promise { const path = getAzureApiUrl(repositoryUrl).toString(); const host = getGitHost(repositoryUrl); - const options = getAzureRequestOptions(config, host); + + const integrationConfig = getAzureIntegrationConfig(config, host); + const options = getAzureRequestOptions(integrationConfig); try { const urlResponse = await fetch(path, options); @@ -235,12 +195,8 @@ async function getAzureDefaultBranch( export const getDefaultBranch = async ( repositoryUrl: string, + config: Config, ): Promise => { - // TODO(Rugvip): Config should not be loaded here, pass it in instead - const config = await loadBackendConfig({ - logger: getRootLogger(), - argv: process.argv, - }); const type = getGitRepoType(repositoryUrl); try { diff --git a/packages/techdocs-common/src/git-auth.ts b/packages/techdocs-common/src/git-auth.ts new file mode 100644 index 0000000000..b58afae490 --- /dev/null +++ b/packages/techdocs-common/src/git-auth.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import parseGitUrl from 'git-url-parse'; +import { Config } from '@backstage/config'; +import { + readGitHubIntegrationConfigs, + readGitLabIntegrationConfigs, + readAzureIntegrationConfigs, + GitHubIntegrationConfig, + GitLabIntegrationConfig, + AzureIntegrationConfig, +} from '@backstage/integration'; + +export function getGitHost(url: string): string { + const { resource } = parseGitUrl(url); + return resource; +} + +export function getGitRepoType(url: string): string { + const typeMapping = [ + { url: /github*/g, type: 'github' }, + { url: /gitlab*/g, type: 'gitlab' }, + { url: /azure*/g, type: 'azure/api' }, + ]; + + const type = typeMapping.filter(item => item.url.test(url))[0]?.type; + + return type; +} + +export const getGitHubIntegrationConfig = ( + config: Config, + host: string, +): GitHubIntegrationConfig => { + const allGitHubConfigs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + const gitHubIntegrationConfig = allGitHubConfigs.find(v => v.host === host); + if (!gitHubIntegrationConfig) { + throw new Error(`Unable to locate GitHub integration for the host ${host}`); + } + return gitHubIntegrationConfig; +}; + +export const getGitLabIntegrationConfig = ( + config: Config, + host: string, +): GitLabIntegrationConfig => { + const allGitLabConfigs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + const gitLabIntegrationConfig = allGitLabConfigs.find(v => v.host === host); + if (!gitLabIntegrationConfig) { + throw new Error(`Unable to locate GitLab integration for the host ${host}`); + } + return gitLabIntegrationConfig; +}; + +export const getAzureIntegrationConfig = ( + config: Config, + host: string, +): AzureIntegrationConfig => { + const allAzureIntegrationConfig = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + const azureIntegrationConfig = allAzureIntegrationConfig.find( + v => v.host === host, + ); + if (!azureIntegrationConfig) { + throw new Error(`Unable to locate Azure integration for the host ${host}`); + } + return azureIntegrationConfig; +}; + +export const getTokenForGitRepo = async ( + repositoryUrl: string, + config: Config, +): Promise => { + const host = getGitHost(repositoryUrl); + const type = getGitRepoType(repositoryUrl); + + try { + switch (type) { + case 'github': + return getGitHubIntegrationConfig(config, host).token; + case 'gitlab': + return getGitLabIntegrationConfig(config, host).token; + case 'azure/api': + return getAzureIntegrationConfig(config, host).token; + default: + throw new Error('Failed to get repository type'); + } + } catch (error) { + throw error; + } +}; diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts new file mode 100644 index 0000000000..740a08aaa2 --- /dev/null +++ b/packages/techdocs-common/src/helpers.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Readable } from 'stream'; +import { + getDocFilesFromRepository, + getLocationForEntity, + parseReferenceAnnotation, +} from './helpers'; +import { UrlReader, ReadTreeResponse } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; + +const entityBase: Entity = { + metadata: { + namespace: 'default', + name: 'mytestcomponent', + description: 'A component for testing', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'documentation', + lifecycle: 'experimental', + owner: 'testuser', + }, +}; + +const metadataBase = { + namespace: 'default', + name: 'mytestcomponent', + description: 'A component for testing', +}; + +const goodAnnotation = { + annotations: { + 'backstage.io/techdocs-ref': + 'url:https://github.com/backstage/backstage/blob/master/subfolder/', + }, +}; + +const mockEntityWithAnnotation: Entity = { + ...entityBase, + ...{ + metadata: { + ...metadataBase, + ...goodAnnotation, + }, + }, +}; + +const badAnnotation = { + annotations: { + 'backstage.io/techdocs-ref': 'bad-annotation', + }, +}; + +const mockEntityWithBadAnnotation: Entity = { + ...entityBase, + ...{ + metadata: { + ...metadataBase, + ...badAnnotation, + }, + }, +}; + +describe('parseReferenceAnnotation', () => { + it('should parse annotation', () => { + const parsedLocationAnnotation = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + mockEntityWithAnnotation, + ); + expect(parsedLocationAnnotation.type).toBe('url'); + expect(parsedLocationAnnotation.target).toBe( + 'https://github.com/backstage/backstage/blob/master/subfolder/', + ); + }); + + it('should throw error without annotation', () => { + expect(() => { + parseReferenceAnnotation('backstage.io/techdocs-ref', entityBase); + }).toThrow(/No location annotation/); + }); + + it('should throw error with bad annotation', () => { + expect(() => { + parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + mockEntityWithBadAnnotation, + ); + }).toThrow(/Failure to parse/); + }); +}); + +describe('getLocationForEntity', () => { + it('should get location for entity', () => { + const parsedLocationAnnotation = getLocationForEntity( + mockEntityWithAnnotation, + ); + expect(parsedLocationAnnotation.type).toBe('url'); + expect(parsedLocationAnnotation.target).toBe( + 'https://github.com/backstage/backstage/blob/master/subfolder/', + ); + }); +}); + +describe('getDocFilesFromRepository', () => { + it('should read a remote directory using UrlReader.readTree', async () => { + class MockUrlReader implements UrlReader { + async read() { + return Buffer.from('mock'); + } + + async readTree(): Promise { + return { + dir: async () => { + return '/tmp/testfolder'; + }, + files: async () => { + return []; + }, + archive: async () => { + return Readable.from(''); + }, + etag: '', + }; + } + } + + const output = await getDocFilesFromRepository( + new MockUrlReader(), + mockEntityWithAnnotation, + ); + + expect(output).toBe('/tmp/testfolder'); + }); +}); diff --git a/plugins/techdocs-backend/src/helpers.ts b/packages/techdocs-common/src/helpers.ts similarity index 69% rename from plugins/techdocs-backend/src/helpers.ts rename to packages/techdocs-common/src/helpers.ts index fcf5a4f194..f70b0f3840 100644 --- a/plugins/techdocs-backend/src/helpers.ts +++ b/packages/techdocs-common/src/helpers.ts @@ -17,19 +17,15 @@ import os from 'os'; import path from 'path'; import parseGitUrl from 'git-url-parse'; -import NodeGit, { Clone, Repository } from 'nodegit'; import fs from 'fs-extra'; +import { InputError, UrlReader, Git } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import { getDefaultBranch } from './default-branch'; import { getGitRepoType, getTokenForGitRepo } from './git-auth'; -import { Entity } from '@backstage/catalog-model'; -import { InputError, UrlReader } from '@backstage/backend-common'; -import { RemoteProtocol } from './techdocs/stages/prepare/types'; +import { RemoteProtocol } from './stages/prepare/types'; import { Logger } from 'winston'; -// Enables core.longpaths on windows to prevent crashing when checking out repos with long foldernames and/or deep nesting -// @ts-ignore -NodeGit.Libgit2.opts(28, 1); - export type ParsedLocationAnnotation = { type: RemoteProtocol; target: string; @@ -94,6 +90,7 @@ export const getLocationForEntity = ( export const getGitRepositoryTempFolder = async ( repositoryUrl: string, + config: Config, ): Promise => { const parsedGitLocation = parseGitUrl(repositoryUrl); // removes .git from git location path @@ -102,6 +99,7 @@ export const getGitRepositoryTempFolder = async ( if (!parsedGitLocation.ref) { parsedGitLocation.ref = await getDefaultBranch( parsedGitLocation.toString('https'), + config, ); } @@ -118,23 +116,68 @@ export const getGitRepositoryTempFolder = async ( export const checkoutGitRepository = async ( repoUrl: string, + config: Config, logger: Logger, ): Promise => { const parsedGitLocation = parseGitUrl(repoUrl); - const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl); - const token = await getTokenForGitRepo(repoUrl); + const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl, config); + const token = await getTokenForGitRepo(repoUrl, config); + // Initialize a git client + let git = Git.fromAuth({ logger }); + + // Docs about why username and password are set to these specific values. + // https://isomorphic-git.org/docs/en/onAuth#oauth2-tokens + if (token) { + const type = getGitRepoType(repoUrl); + switch (type) { + case 'github': + git = Git.fromAuth({ + username: token, + password: 'x-oauth-basic', + logger, + }); + parsedGitLocation.token = `${token}:x-oauth-basic`; + break; + case 'gitlab': + git = Git.fromAuth({ + username: 'oauth2', + password: token, + logger, + }); + parsedGitLocation.token = `dummyUsername:${token}`; + parsedGitLocation.git_suffix = true; + break; + case 'azure/api': + git = Git.fromAuth({ + username: 'notempty', + password: token, + logger: logger, + }); + break; + default: + parsedGitLocation.token = `:${token}`; + } + } + + // Pull from repository if it has already been cloned. if (fs.existsSync(repositoryTmpPath)) { try { - const repository = await Repository.open(repositoryTmpPath); - const currentBranchName = ( - await repository.getCurrentBranch() - ).shorthand(); - await repository.fetch('origin'); - await repository.mergeBranches( - currentBranchName, - `origin/${currentBranchName}`, - ); + const currentBranchName = await git.currentBranch({ + dir: repositoryTmpPath, + }); + + await git.fetch({ dir: repositoryTmpPath, remote: 'origin' }); + await git.merge({ + dir: repositoryTmpPath, + theirs: `origin/${currentBranchName}`, + ours: currentBranchName || undefined, + author: { name: 'Backstage TechDocs', email: 'techdocs@backstage.io' }, + committer: { + name: 'Backstage TechDocs', + email: 'techdocs@backstage.io', + }, + }); return repositoryTmpPath; } catch (e) { logger.info( @@ -144,40 +187,30 @@ export const checkoutGitRepository = async ( } } - if (token) { - const type = getGitRepoType(repoUrl); - switch (type) { - case 'gitlab': - // Personal Access Token - parsedGitLocation.token = `dummyUsername:${token}`; - parsedGitLocation.git_suffix = true; - break; - case 'github': - parsedGitLocation.token = `${token}:x-oauth-basic`; - break; - default: - parsedGitLocation.token = `:${token}`; - } - } - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); fs.mkdirSync(repositoryTmpPath, { recursive: true }); - await Clone.clone(repositoryCheckoutUrl, repositoryTmpPath); + await git.clone({ url: repositoryCheckoutUrl, dir: repositoryTmpPath }); return repositoryTmpPath; }; export const getLastCommitTimestamp = async ( repositoryUrl: string, + config: Config, logger: Logger, ): Promise => { - const repositoryLocation = await checkoutGitRepository(repositoryUrl, logger); + const repositoryLocation = await checkoutGitRepository( + repositoryUrl, + config, + logger, + ); - const repository = await Repository.open(repositoryLocation); - const commit = await repository.getReferenceCommit('HEAD'); + const git = Git.fromAuth({ logger }); + const sha = await git.resolveRef({ dir: repositoryLocation, ref: 'HEAD' }); + const commit = await git.readCommit({ dir: repositoryLocation, sha }); - return commit.date().getTime(); + return commit.commit.committer.timestamp; }; export const getDocFilesFromRepository = async ( diff --git a/packages/techdocs-common/src/index.ts b/packages/techdocs-common/src/index.ts new file mode 100644 index 0000000000..a6e1831049 --- /dev/null +++ b/packages/techdocs-common/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './stages'; +export * from './helpers'; +export * from './default-branch'; +export * from './git-auth'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs.yml b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs.yml similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs.yml rename to packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs.yml diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs_with_repo_url.yml b/packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_with_repo_url.yml similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/__fixtures__/mkdocs_with_repo_url.yml rename to packages/techdocs-common/src/stages/generate/__fixtures__/mkdocs_with_repo_url.yml diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts b/packages/techdocs-common/src/stages/generate/generators.test.ts similarity index 89% rename from plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts rename to packages/techdocs-common/src/stages/generate/generators.test.ts index a9303c3794..3b59d6d007 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts +++ b/packages/techdocs-common/src/stages/generate/generators.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Generators, TechdocsGenerator } from './'; +import { Generators, TechdocsGenerator } from '.'; import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; @@ -39,10 +39,7 @@ describe('generators', () => { it('should return correct registered generator', async () => { const generators = new Generators(); - const techdocs = new TechdocsGenerator( - logger, - ConfigReader.fromConfigs([]), - ); + const techdocs = new TechdocsGenerator(logger, new ConfigReader({})); generators.register('techdocs', techdocs); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts b/packages/techdocs-common/src/stages/generate/generators.ts similarity index 75% rename from plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts rename to packages/techdocs-common/src/stages/generate/generators.ts index f28a169942..cbfa5e7553 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts +++ b/packages/techdocs-common/src/stages/generate/generators.ts @@ -14,18 +14,32 @@ * limitations under the License. */ +import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { TechdocsGenerator } from '.'; import { GeneratorBase, SupportedGeneratorKey, GeneratorBuilder, } from './types'; - -import { Entity } from '@backstage/catalog-model'; import { getGeneratorKey } from './helpers'; export class Generators implements GeneratorBuilder { private generatorMap = new Map(); + static async fromConfig( + config: Config, + { logger }: { logger: Logger }, + ): Promise { + const generators = new Generators(); + + const techdocsGenerator = new TechdocsGenerator(logger, config); + generators.register('techdocs', techdocsGenerator); + + return generators; + } + register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase) { this.generatorMap.set(generatorKey, generator); } diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts similarity index 95% rename from plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts rename to packages/techdocs-common/src/stages/generate/helpers.test.ts index 54ef0b1e83..4d62648a7d 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -28,7 +28,7 @@ import { patchMkdocsYmlPreBuild, } from './helpers'; import { RemoteProtocol } from '../prepare/types'; -import { ParsedLocationAnnotation } from '../../../helpers'; +import { ParsedLocationAnnotation } from '../../helpers'; const mockEntity = { apiVersion: 'version', @@ -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/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts similarity index 96% rename from plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts rename to packages/techdocs-common/src/stages/generate/helpers.ts index 0f60712f5e..e396157b92 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -22,7 +22,7 @@ import yaml from 'js-yaml'; import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; import { SupportedGeneratorKey } from './types'; -import { ParsedLocationAnnotation } from '../../../helpers'; +import { ParsedLocationAnnotation } from '../../helpers'; import { RemoteProtocol } from '../prepare/types'; // TODO: Implement proper support for more generators. @@ -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, }, @@ -209,7 +209,7 @@ export const getRepoUrlFromLocationAnnotation = ( }; /** - * Update the mkdocs.yml file before TechDocs generator uses it to build docs site. + * Update the mkdocs.yml file before TechDocs generator uses it to generate docs site. * * List of tasks: * - Add repo_url if it does not exists @@ -241,7 +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/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts b/packages/techdocs-common/src/stages/generate/index.ts similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/generate/index.ts rename to packages/techdocs-common/src/stages/generate/index.ts diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts similarity index 71% rename from plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts rename to packages/techdocs-common/src/stages/generate/techdocs.ts index faefd8ac16..507b9da6b8 100644 --- a/plugins/techdocs-backend/src/techdocs/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/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts similarity index 71% rename from plugins/techdocs-backend/src/techdocs/stages/generate/types.ts rename to packages/techdocs-common/src/stages/generate/types.ts index 6d9ce5afca..7724107dac 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -16,34 +16,28 @@ import { Writable } from 'stream'; import Docker from 'dockerode'; import { Entity } from '@backstage/catalog-model'; -import { ParsedLocationAnnotation } from '../../../helpers'; - -/** - * The returned directory from the generator which is ready - * to pass to the next stage of the TechDocs which is publishing - */ -export type GeneratorRunResult = { - resultDir: string; -}; +import { ParsedLocationAnnotation } from '../../helpers'; /** * The values that the generator will receive. * - * @param {string} directory The directory of the uncompiled documentation, with the values from the frontend + * @param {string} inputDir The directory of the uncompiled documentation, with the values from the frontend + * @param {string} outputDir Directory to store generated docs in. Usually - a newly created temporary directory. * @param {Docker} dockerClient A docker client to run any generator on top of your directory * @param {ParsedLocationAnnotation} parsedLocationAnnotation backstage.io/techdocs-ref annotation of an entity * @param {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/plugins/techdocs-backend/src/techdocs/stages/index.ts b/packages/techdocs-common/src/stages/index.ts similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/index.ts rename to packages/techdocs-common/src/stages/index.ts diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts similarity index 86% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts rename to packages/techdocs-common/src/stages/prepare/commonGit.test.ts index 843df476e9..704cd23daa 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts @@ -15,8 +15,9 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { CommonGitPreparer } from './commonGit'; -import { checkoutGitRepository } from '../../../helpers'; +import { checkoutGitRepository } from '../../helpers'; function normalizePath(path: string) { return path @@ -25,8 +26,8 @@ function normalizePath(path: string) { .join('/'); } -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), +jest.mock('../../helpers', () => ({ + ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), })); @@ -43,11 +44,13 @@ const createMockEntity = (annotations = {}) => { }; }; +const mockConfig = new ConfigReader({}); + const logger = getVoidLogger(); describe('commonGit preparer', () => { it('should prepare temp docs path from github repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': @@ -62,7 +65,7 @@ describe('commonGit preparer', () => { }); it('should prepare temp docs path from gitlab repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': @@ -77,7 +80,7 @@ describe('commonGit preparer', () => { }); it('should prepare temp docs path from azure repo', async () => { - const preparer = new CommonGitPreparer(logger); + const preparer = new CommonGitPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts b/packages/techdocs-common/src/stages/prepare/commonGit.ts similarity index 78% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts rename to packages/techdocs-common/src/stages/prepare/commonGit.ts index d9ba96a031..7eac07d76f 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.ts @@ -14,20 +14,20 @@ * limitations under the License. */ import path from 'path'; -import { Entity } from '@backstage/catalog-model'; -import { PreparerBase } from './types'; import parseGitUrl from 'git-url-parse'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { PreparerBase } from './types'; +import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; import { Logger } from 'winston'; export class CommonGitPreparer implements PreparerBase { + private readonly config: Config; private readonly logger: Logger; - constructor(logger: Logger) { + constructor(config: Config, logger: Logger) { + this.config = config; this.logger = logger; } @@ -38,7 +38,11 @@ export class CommonGitPreparer implements PreparerBase { ); try { - const repoPath = await checkoutGitRepository(target, this.logger); + const repoPath = await checkoutGitRepository( + target, + this.config, + this.logger, + ); const parsedGitLocation = parseGitUrl(target); return path.join(repoPath, parsedGitLocation.filepath); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts similarity index 85% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts rename to packages/techdocs-common/src/stages/prepare/dir.test.ts index dc2b1d7d48..d082b1367b 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DirectoryPreparer } from './dir'; import { getVoidLogger } from '@backstage/backend-common'; -import { checkoutGitRepository } from '../../../helpers'; +import { ConfigReader } from '@backstage/config'; +import { DirectoryPreparer } from './dir'; +import { checkoutGitRepository } from '../../helpers'; function normalizePath(path: string) { return path @@ -24,8 +25,8 @@ function normalizePath(path: string) { .join('/'); } -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), +jest.mock('../../helpers', () => ({ + ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch/'), })); @@ -44,9 +45,11 @@ const createMockEntity = (annotations: {}) => { }; }; +const mockConfig = new ConfigReader({}); + describe('directory preparer', () => { it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => { - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -60,7 +63,7 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -74,7 +77,7 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer(mockConfig, logger); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts similarity index 84% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts rename to packages/techdocs-common/src/stages/prepare/dir.ts index 1effcf61b5..537f670edc 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -15,19 +15,19 @@ */ import { PreparerBase } from './types'; import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import path from 'path'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; +import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; import { InputError } from '@backstage/backend-common'; import parseGitUrl from 'git-url-parse'; import { Logger } from 'winston'; export class DirectoryPreparer implements PreparerBase { + private readonly config: Config; private readonly logger: Logger; - constructor(logger: Logger) { + constructor(config: Config, logger: Logger) { + this.config = config; this.logger = logger; } @@ -43,9 +43,14 @@ export class DirectoryPreparer implements PreparerBase { switch (type) { case 'github': case 'gitlab': + case 'url': case 'azure/api': { const parsedGitLocation = parseGitUrl(target); - const repoLocation = await checkoutGitRepository(target, this.logger); + const repoLocation = await checkoutGitRepository( + target, + this.config, + this.logger, + ); return path.dirname( path.join(repoLocation, parsedGitLocation.filepath), diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/packages/techdocs-common/src/stages/prepare/index.ts similarity index 90% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts rename to packages/techdocs-common/src/stages/prepare/index.ts index 6c73725a3a..dcb178bafa 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts +++ b/packages/techdocs-common/src/stages/prepare/index.ts @@ -17,4 +17,4 @@ export { DirectoryPreparer } from './dir'; export { CommonGitPreparer } from './commonGit'; export { UrlPreparer } from './url'; export { Preparers } from './preparers'; -export type { PreparerBuilder, PreparerBase } from './types'; +export type { PreparerBuilder, PreparerBase, RemoteProtocol } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts similarity index 56% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts rename to packages/techdocs-common/src/stages/prepare/preparers.ts index 2f0df47de4..4625d7b0e4 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -13,14 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; +import { Logger } from 'winston'; +import { UrlReader } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { parseReferenceAnnotation } from '../../../helpers'; +import { Config } from '@backstage/config'; +import { DirectoryPreparer, CommonGitPreparer, UrlPreparer } from '.'; +import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; +import { parseReferenceAnnotation } from '../../helpers'; + +type factoryOptions = { + logger: Logger; + reader: UrlReader; +}; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); + static async fromConfig( + config: Config, + { logger, reader }: factoryOptions, + ): Promise { + const preparers = new Preparers(); + + const directoryPreparer = new DirectoryPreparer(config, logger); + preparers.register('dir', directoryPreparer); + + const commonGitPreparer = new CommonGitPreparer(config, logger); + preparers.register('github', commonGitPreparer); + preparers.register('gitlab', commonGitPreparer); + preparers.register('azure/api', commonGitPreparer); + + const urlPreparer = new UrlPreparer(reader, logger); + preparers.register('url', urlPreparer); + + return preparers; + } + register(protocol: RemoteProtocol, preparer: PreparerBase) { this.preparerMap.set(protocol, preparer); } diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts b/packages/techdocs-common/src/stages/prepare/types.ts similarity index 100% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts rename to packages/techdocs-common/src/stages/prepare/types.ts diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts b/packages/techdocs-common/src/stages/prepare/url.ts similarity index 95% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts rename to packages/techdocs-common/src/stages/prepare/url.ts index 330db05aa4..b938c6b5bc 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts +++ b/packages/techdocs-common/src/stages/prepare/url.ts @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { PreparerBase } from './types'; -import { getDocFilesFromRepository } from '../../../helpers'; - import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; import { UrlReader } from '@backstage/backend-common'; +import { PreparerBase } from './types'; +import { getDocFilesFromRepository } from '../../helpers'; export class UrlPreparer implements PreparerBase { private readonly logger: Logger; 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 new file mode 100644 index 0000000000..f8fb00647c --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import mockFs from 'mock-fs'; +import * as winston from 'winston'; +import { ConfigReader } from '@backstage/config'; +import { GoogleGCSPublish } from './googleStorage'; +import { PublisherBase } from './types'; + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + annotations: { + ...annotations, + }, + }, + }; +}; + +const logger = winston.createLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'bucketName', + }, + }, + }, + }); + + publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger); +}); + +describe('GoogleGCSPublish', () => { + it('should publish a directory', async () => { + mockFs({ + '/path/to/generatedDirectory': { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + const entity = createMockEntity(); + expect( + await publisher.publish({ + entity, + directory: '/path/to/generatedDirectory', + }), + ).toBeUndefined(); + mockFs.restore(); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts new file mode 100644 index 0000000000..8876007504 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { + Storage, + UploadResponse, + FileExistsResponse, +} from '@google-cloud/storage'; +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 JSON5 from 'json5'; +import createLimiter from 'p-limit'; + +export class GoogleGCSPublish implements PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { + let bucketName = ''; + try { + bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'googleGcs' in your app config, " + + 'techdocs.publisher.googleGcs.bucketName is required.', + ); + } + + // Credentials is an optional config. If missing, default GCS environment variables will be used. + // Read more here https://cloud.google.com/docs/authentication/production + const credentials = config.getOptionalString( + 'techdocs.publisher.googleGcs.credentials', + ); + let credentialsJson = {}; + if (credentials) { + try { + credentialsJson = JSON.parse(credentials); + } catch (err) { + throw new Error( + 'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.', + ); + } + } + + const storageClient = new Storage({ + ...(credentials && { + credentials: credentialsJson, + }), + }); + + // Check if the defined bucket exists. Being able to connect means the configuration is good + // and the storage client will work. + try { + await storageClient.bucket(bucketName).getMetadata(); + logger.info(`Successfully connected to the GCS bucket ${bucketName}.`); + } catch (err) { + logger.error( + `Could not retrieve metadata about the GCS bucket ${bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + + 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(err.message); + } + + return new GoogleGCSPublish(storageClient, bucketName, logger); + } + + constructor( + private readonly storageClient: Storage, + private readonly bucketName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.bucketName = bucketName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the GCS bucket. + * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html + */ + publish({ entity, directory }: PublishRequest): Promise { + return new Promise(async (resolve, reject) => { + // Note: GCS manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const limiter = createLimiter(10); + const uploadPromises: Array> = []; + allFilesToUpload.forEach(filePath => { + // Remove the absolute path prefix of the source directory + // Path of all files to upload, relative to the root of the source directory + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = `${entityRootDir}/${relativeFilePath}`; // GCS Bucket file relative path + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => + this.storageClient + .bucket(this.bucketName) + .upload(filePath, { destination }), + ); + uploadPromises.push(uploadFile); + }); + + Promise.all(uploadPromises) + .then(() => { + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + resolve(undefined); + }) + .catch((err: Error) => { + const errorMessage = `Unable to upload file(s) to Google Cloud Storage. Error ${err.message}`; + this.logger.error(errorMessage); + reject(errorMessage); + }); + }); + } + + fetchTechDocsMetadata(entityName: EntityName): Promise { + return new Promise((resolve, reject) => { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + + const fileStreamChunks: Array = []; + this.storageClient + .bucket(this.bucketName) + .file(`${entityRootDir}/techdocs_metadata.json`) + .createReadStream() + .on('error', err => { + this.logger.error(err.message); + reject(err.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + const techdocsMetadataJson = Buffer.concat( + fileStreamChunks, + ).toString(); + resolve(JSON5.parse(techdocsMetadataJson)); + }); + }); + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return (req, res) => { + // Trim the leading forward slash + // filePath example - /default/Component/documented-component/index.html + const filePath = req.path.replace(/^\//, ''); + + // Files with different extensions (CSS, HTML) need to be served with different headers + const fileExtension = path.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + // Pipe file chunks directly from storage to client. + this.storageClient + .bucket(this.bucketName) + .file(filePath) + .createReadStream() + .on('pipe', () => { + res.writeHead(200, responseHeaders); + }) + .on('error', err => { + this.logger.warn(err.message); + // Send a 404 with a meaningful message if possible. + if (!res.headersSent) { + res.status(404).send(err.message); + } else { + res.destroy(); + } + }) + .pipe(res); + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + return new Promise(resolve => { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + this.storageClient + .bucket(this.bucketName) + .file(`${entityRootDir}/index.html`) + .exists() + .then((response: FileExistsResponse) => { + resolve(response[0]); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/helpers.test.ts b/packages/techdocs-common/src/stages/publish/helpers.test.ts new file mode 100644 index 0000000000..1fae66f3d2 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/helpers.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import mockFs from 'mock-fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; + +describe('getHeadersForFileExtension', () => { + const correctMapOfExtensions = [ + ['.html', 'text/html; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.svg', 'image/svg+xml'], + ['.json', 'application/json; charset=utf-8'], + ['.this-in-not-an-extension', 'text/plain; charset=utf-8'], + ]; + + test.each(correctMapOfExtensions)( + 'check content-type for %s extension', + (extension, expectedContentType) => { + const headers = getHeadersForFileExtension(extension); + expect(headers).toHaveProperty('Content-Type'); + expect(headers['Content-Type'].toLowerCase()).toBe(expectedContentType); + }, + ); +}); + +describe('getFileTreeRecursively', () => { + const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + + beforeEach(() => { + mockFs({ + [root]: { + file1: '', + subDirA: { + file2: '', + emptyDir1: mockFs.directory(), + }, + emptyDir2: mockFs.directory(), + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('returns complete file tree of a path', async () => { + const fileList = await getFileTreeRecursively(root); + expect(fileList.length).toBe(2); + expect(fileList).toContain(path.resolve(root, 'file1')); + expect(fileList).toContain(path.resolve(root, 'subDirA/file2')); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/helpers.ts b/packages/techdocs-common/src/stages/publish/helpers.ts new file mode 100644 index 0000000000..138ec611e0 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/helpers.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import mime from 'mime-types'; +import recursiveReadDir from 'recursive-readdir'; + +export type responseHeadersType = { + 'Content-Type': string; +}; + +/** + * Some files need special headers to be used correctly by the frontend. This function + * generates headers in the response to those file requests. + * @param {string} fileExtension .html, .css, .js, .png etc. + */ +export const getHeadersForFileExtension = ( + fileExtension: string, +): responseHeadersType => { + return { + 'Content-Type': + mime.contentType(fileExtension) || 'text/plain; charset=utf-8', + } as responseHeadersType; +}; + +/** + * Recursively traverse all the sub-directories of a path and return + * a list of absolute paths of all the files. e.g. tree command in Unix + * + * @example + * + * /User/username/my_dir + * dirA + * | subDirA + * | | file1 + * EmptyDir + * dirB + * | file2 + * file3 + * + * getFileListRecursively('/Users/username/myDir') + * // returns + * [ + * '/User/username/my_dir/dirA/subDirA/file1', + * '/User/username/my_dir/dirB/file2', + * '/User/username/my_dir/file3' + * ] + * @param rootDirPath Absolute path to the root directory. + */ +export const getFileTreeRecursively = async ( + rootDirPath: string, +): Promise => { + // Iterate on all the files in the directory and its sub-directories + const fileList = await recursiveReadDir(rootDirPath).catch(error => { + throw new Error(`Failed to read template directory: ${error.message}`); + }); + return fileList; +}; diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts new file mode 100644 index 0000000000..dbf4ff732a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Publisher } from './publish'; +export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts similarity index 64% rename from plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts rename to packages/techdocs-common/src/stages/publish/local.test.ts index 18344489e1..efd56285ef 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -21,8 +21,26 @@ import { getVoidLogger, PluginEndpointDiscovery, } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { LocalPublish } from './local'; +jest.mock('fs-extra', () => { + const fsOriginal = jest.requireActual('fs-extra'); + return { + ...fsOriginal, + access: jest.fn().mockImplementation((path, checkType, callback) => { + if ( + path.includes('http://localhost:7000/static') && + checkType === fs.constants.F_OK + ) { + callback(); + } else { + callback(new Error()); + } + }), + }; +}); + const createMockEntity = (annotations = {}) => { return { apiVersion: 'version', @@ -41,34 +59,39 @@ const logger = getVoidLogger(); describe('local publisher', () => { it('should publish generated documentation dir', async () => { const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), getExternalBaseUrl: jest.fn(), }; - const publisher = new LocalPublish(logger, testDiscovery); + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + storageUrl: 'http://localhost:7000/static/docs', + }, + }); + const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); - const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); - expect(tempDir).toBeTruthy(); fs.closeSync(fs.openSync(path.join(tempDir, '/mock-file'), 'w')); - await publisher.publish({ entity: mockEntity, directory: tempDir }); + const publishDir = path.resolve( __dirname, - `../../../../static/docs/${mockEntity.metadata.name}`, + `../../../../../plugins/techdocs-backend/static/docs/${mockEntity.metadata.name}`, ); - const resultDir = path.resolve( __dirname, - `../../../../static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, + `../../../../../plugins/techdocs-backend/static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, ); expect(fs.existsSync(resultDir)).toBeTruthy(); expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); + expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); + fs.removeSync(publishDir); fs.removeSync(tempDir); }); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts new file mode 100644 index 0000000000..d07472e17d --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 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 { + resolvePackagePath, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { + PublisherBase, + PublishRequest, + PublishResponse, + TechDocsMetadata, +} from './types'; + +// TODO: Use a more persistent storage than node_modules or /tmp directory. +// Make it configurable with techdocs.publisher.local.publishDirectory +let staticDocsDir = ''; +try { + staticDocsDir = resolvePackagePath( + '@backstage/plugin-techdocs-backend', + 'static/docs', + ); +} catch (err) { + // This will most probably never be used. + // The try/catch is introduced so that techdocs-cli can import @backstage/techdocs-common + // on CI/CD without installing techdocs backend plugin. + staticDocsDir = os.tmpdir(); +} + +/** + * Local publisher which uses the local filesystem to store the generated static files. It uses a directory + * called "static" at the root of techdocs-backend plugin. + */ +export class LocalPublish implements PublisherBase { + private readonly config: Config; + private readonly logger: Logger; + private readonly discovery: PluginEndpointDiscovery; + + // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. + // Move the logic of setting staticDocsDir based on config over to fromConfig, + // and set the value as a class parameter. + constructor( + config: Config, + logger: Logger, + discovery: PluginEndpointDiscovery, + ) { + this.config = config; + this.logger = logger; + this.discovery = discovery; + } + + publish({ entity, directory }: PublishRequest): Promise { + const entityNamespace = entity.metadata.namespace ?? 'default'; + + const publishDir = path.join( + staticDocsDir, + entityNamespace, + entity.kind, + entity.metadata.name, + ); + + if (!fs.existsSync(publishDir)) { + this.logger.info(`Could not find ${publishDir}, creating the directory.`); + fs.mkdirSync(publishDir, { recursive: true }); + } + + return new Promise((resolve, reject) => { + fs.copy(directory, publishDir, err => { + if (err) { + this.logger.debug( + `Failed to copy docs from ${directory} to ${publishDir}`, + ); + reject(err); + } + + this.discovery + .getBaseUrl('techdocs') + .then(techdocsApiUrl => { + resolve({ + remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, + }); + }) + .catch(reason => { + reject(reason); + }); + }); + }); + } + + fetchTechDocsMetadata(entityName: EntityName): Promise { + return new Promise((resolve, reject) => { + this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { + const storageUrl = new URL( + new URL(this.config.getString('techdocs.storageUrl')).pathname, + techdocsApiUrl, + ).toString(); + + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const metadataURL = `${storageUrl}/${entityRootDir}/techdocs_metadata.json`; + fetch(metadataURL) + .then(response => + response + .json() + .then(techdocsMetadata => resolve(techdocsMetadata)) + .catch(err => { + reject( + `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, + ); + }), + ) + .catch(err => { + reject( + `Unable to fetch metadata for ${entityRootDir}. Error ${err}`, + ); + }); + }); + }); + } + + docsRouter(): express.Handler { + return express.static(staticDocsDir); + } + + async hasDocsBeenGenerated(entity: Entity): Promise { + const namespace = entity.metadata.namespace ?? 'default'; + return new Promise(resolve => { + this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { + const storageUrl = new URL( + new URL(this.config.getString('techdocs.storageUrl')).pathname, + techdocsApiUrl, + ).toString(); + + const entityRootDir = `${namespace}/${entity.kind}/${entity.metadata.name}`; + const indexHtmlUrl = `${storageUrl}/${entityRootDir}/index.html`; + // Check if the file exists + fs.access(indexHtmlUrl, fs.constants.F_OK, err => { + if (err) { + resolve(false); + } else { + resolve(true); + } + }); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts new file mode 100644 index 0000000000..89f2c0ffdd --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { Publisher } from './publish'; +import { LocalPublish } from './local'; +import { GoogleGCSPublish } from './googleStorage'; +import { AwsS3Publish } from './awsS3'; + +const logger = getVoidLogger(); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getExternalBaseUrl: jest.fn(), +}; + +describe('Publisher', () => { + it('should create local publisher by default', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(LocalPublish); + }); + + it('should create local publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'local', + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(LocalPublish); + }); + + it('should create google gcs publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'bucketName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(GoogleGCSPublish); + }); + + it('should create AWS S3 publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + bucketName: 'bucketName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AwsS3Publish); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts new file mode 100644 index 0000000000..82232c2fd1 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +import { PublisherType, PublisherBase } from './types'; +import { LocalPublish } from './local'; +import { GoogleGCSPublish } from './googleStorage'; +import { AwsS3Publish } from './awsS3'; + +type factoryOptions = { + logger: Logger; + discovery: PluginEndpointDiscovery; +}; + +/** + * Factory class to create a TechDocs publisher based on defined publisher type in app config. + * Uses `techdocs.publisher.type`. + */ +export class Publisher { + static async fromConfig( + config: Config, + { logger, discovery }: factoryOptions, + ): Promise { + const publisherType = (config.getOptionalString( + 'techdocs.publisher.type', + ) ?? 'local') as PublisherType; + + switch (publisherType) { + case 'googleGcs': + logger.info('Creating Google Storage Bucket publisher for TechDocs'); + return await GoogleGCSPublish.fromConfig(config, logger); + case 'awsS3': + logger.info('Creating AWS S3 Bucket publisher for TechDocs'); + return AwsS3Publish.fromConfig(config, logger); + case 'local': + logger.info('Creating Local publisher for TechDocs'); + return new LocalPublish(config, logger, discovery); + default: + logger.info('Creating Local publisher for TechDocs'); + return new LocalPublish(config, logger, discovery); + } + } +} diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts new file mode 100644 index 0000000000..db6a075d43 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity, EntityName } from '@backstage/catalog-model'; +import express from 'express'; + +/** + * Key for all the different types of TechDocs publishers that are supported. + */ +export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; + +export type PublishRequest = { + entity: Entity; + /* The Path to the directory where the generated files are stored. */ + directory: string; +}; + +/* `remoteUrl` is the URL which serves files from the local publisher's static directory. */ +export type PublishResponse = { + remoteUrl?: string; +} | void; + +/** + * Type to hold metadata found in techdocs_metadata.json and associated with each site + */ +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + +/** + * Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.) + * The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs. + * It also provides APIs to communicate with the storage service. + */ +export interface PublisherBase { + /** + * Store the generated static files onto a storage service (either local filesystem or external service). + * + * @param request Object containing the entity from the service + * catalog, and the directory that contains the generated static files from TechDocs. + */ + publish(request: PublishRequest): Promise; + + /** + * Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc. + * This API uses the techdocs_metadata.json file that co-exists along with the generated docs. + */ + fetchTechDocsMetadata(entityName: EntityName): Promise; + + /** + * Route middleware to serve static documentation files for an entity. + */ + docsRouter(): express.Handler; + + /** + * Check if the index.html is present for the Entity at the Storage location. + */ + hasDocsBeenGenerated(entityName: Entity): Promise; +} diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index b4bc2bbe76..6d23f26691 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/test-utils +## 0.1.6 + +### Patch Changes + +- 1dc445e89: Update to use new plugin extension API +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + +## 0.1.5 + +### Patch Changes + +- e1f4e24ef: Fix @backstage/cli not being a devDependency +- Updated dependencies [b6557c098] +- Updated dependencies [d8d5a17da] +- Updated dependencies [1665ae8bb] + - @backstage/core-api@0.2.5 + - @backstage/theme@0.2.2 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [4a655c89d] +- Updated dependencies [8a16e8af8] +- Updated dependencies [00670a96e] + - @backstage/cli@0.4.0 + - @backstage/core-api@0.2.4 + ## 0.1.3 ### Patch Changes diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md index 38bf1e83cf..0f95d4ab26 100644 --- a/packages/test-utils/README.md +++ b/packages/test-utils/README.md @@ -4,7 +4,7 @@ This package provides utilities that can be used to test plugins and apps for Ba ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save-dev @backstage/test-utils diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a8f5cd1661..8c5a7c1f39 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.3", + "version": "0.1.6", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.3.0", - "@backstage/core-api": "^0.2.0", + "@backstage/core-api": "^0.2.7", "@backstage/test-utils-core": "^0.1.1", - "@backstage/theme": "^0.2.0", + "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", @@ -46,6 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/cli": "^0.4.3", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 464c547ecc..29f8d97793 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp, renderInTestApp } from './appWrappers'; import { Route, Routes } from 'react-router'; @@ -54,7 +54,7 @@ describe('wrapInTestApp', () => { it('should render a component in a test app without warning about missing act()', async () => { const { error } = await withLogCollector(['error'], async () => { - const Foo: FC<{}> = () => { + const Foo = () => { return

foo

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

foo

; }; @@ -75,7 +75,7 @@ describe('wrapInTestApp', () => { }); it('should provide mock API implementations', async () => { - const A: FC<{}> = () => { + const A = () => { const errorApi = useApi(errorApiRef); errorApi.post(new Error('NOPE')); return null; @@ -96,7 +96,7 @@ describe('wrapInTestApp', () => { it('should allow custom API implementations', async () => { const mockErrorApi = new MockErrorApi({ collect: true }); - const A: FC<{}> = () => { + const A = () => { const errorApi = useApi(errorApiRef); useEffect(() => { errorApi.post(new Error('NOPE')); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 182de1ef63..1f6c7622b8 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ComponentType, ReactNode, FC, ReactElement } from 'react'; +import React, { ComponentType, ReactNode, ReactElement } from 'react'; import { MemoryRouter } from 'react-router'; import { Route } from 'react-router-dom'; import { lightTheme } from '@backstage/theme'; @@ -31,7 +31,7 @@ const { PrivateAppImpl } = privateExports; const NotFoundErrorPage = () => { throw new Error('Reached NotFound Page'); }; -const BootErrorPage: FC = ({ step, error }) => { +const BootErrorPage = ({ step, error }: BootErrorPageProps) => { throw new Error(`Reached BootError Page at step ${step} with error ${error}`); }; const Progress = () =>
; @@ -80,13 +80,14 @@ export function wrapInTestApp( }, ], defaultApis: mockApis, + bindRoutes: () => {}, }); let Wrapper: ComponentType; if (Component instanceof Function) { Wrapper = Component; } else { - Wrapper = (() => Component) as FC; + Wrapper = () => Component as React.ReactElement; } const AppProvider = app.getProvider(); diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 08cf256c48..13d95ab856 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/theme +## 0.2.2 + +### Patch Changes + +- 1665ae8bb: Add a little more padding in dense tables + ## 0.2.1 ### Patch Changes diff --git a/packages/theme/README.md b/packages/theme/README.md index 4b29738193..9855d6730d 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -4,7 +4,7 @@ This package provides the extended Material UI Theme(s) that power Backstage. ## Installation -Install the package via npm or yarn: +Install the package via npm or Yarn: ```sh $ npm install --save @backstage/theme diff --git a/packages/theme/package.json b/packages/theme/package.json index 68c70c656a..54f43a1443 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.1", + "version": "0.2.2", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0" + "@backstage/cli": "^0.4.1" }, "files": [ "dist" diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 193f6961d6..37b8e3b47f 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -135,7 +135,7 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { borderBottom: 0, }, sizeSmall: { - padding: theme.spacing(1, 2, 1, 2.5), + padding: theme.spacing(1.5, 2, 1.5, 2.5), }, head: { wordBreak: 'break-word', diff --git a/plugins/README.md b/plugins/README.md index 6651ba079f..385e71b6d2 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,14 +1,12 @@ # Plugins -Backstage is a single-page application composed of a set of plugins. +Backstage is a single-page application composed of a set of plugins. This folder holds numerous plugins that are managed by this repository. -Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/backstage/backstage/blob/master/docs/dls/design.md) we ensure the overall user experience stays consistent between plugins. +For more information about the plugin ecosystem, see the documentation here: -![plugin](../docs/assets/my-plugin_screenshot.png) +> https://backstage.io/docs/plugins/ -## Creating a plugin - -To create a plugin, follow the steps outlined [here](https://github.com/backstage/backstage/blob/master/docs/plugins/create-a-plugin.md). +You can also see the [Plugin Marketplace](https://backstage.io/plugins) for other open source plugins you can add to your Backstage instance. ## Suggesting a plugin diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index efa94fb46f..01e34d8e1d 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,84 @@ # @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 + +- 9161531b2: Link register API to catalog-import plugin +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [87c0c53c2] + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + +## 0.4.1 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [8ef71ed32] +- Updated dependencies [0e6298f7e] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/plugin-catalog@0.2.7 + +## 0.4.0 + +### Minor Changes + +- 246799c7f: Stop exposing a custom router from the `api-docs` plugin. Instead, use the + widgets exported by the plugin to compose your custom entity pages. + + Instead of displaying the API definitions directly in the API tab of the + component, it now contains tables linking to the API entities. This also adds + new widgets to display relationships (bot provides & consumes relationships) + between components and APIs. + + See the changelog of `create-app` for a migration guide. + +### Patch Changes + +- Updated dependencies [2527628e1] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.1 + +### Patch Changes + +- 7eb8bfe4a: Update swagger-ui-react to 3.37.2 +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 + ## 0.3.0 ### Minor Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index f9719226ee..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.3.0", + "version": "0.4.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/api-docs" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.3.1", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.4", - "@backstage/theme": "^0.2.1", - "@kyma-project/asyncapi-react": "^0.14.2", + "@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", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -40,9 +49,9 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@backstage/dev-utils": "^0.1.4", - "@backstage/test-utils": "^0.1.3", + "@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", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx deleted file mode 100644 index e2148184b7..0000000000 --- a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentEntity, Entity } from '@backstage/catalog-model'; -import { Progress } from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import React from 'react'; -import { - ApiDefinitionCard, - useComponentApiEntities, - useComponentApiNames, -} from '../../components'; - -type Props = { - entity: Entity; -}; - -export const EntityPageApi = ({ entity }: Props) => { - const apiNames = useComponentApiNames(entity as ComponentEntity); - - const { apiEntities, loading } = useComponentApiEntities({ - entity: entity as ComponentEntity, - }); - - if (loading) { - return ; - } - - return ( - - {apiNames.map(api => ( - - - - ))} - - ); -}; diff --git a/plugins/api-docs/src/catalog/Router.tsx b/plugins/api-docs/src/catalog/Router.tsx deleted file mode 100644 index 64c074fc46..0000000000 --- a/plugins/api-docs/src/catalog/Router.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; -import { Route, Routes } from 'react-router'; -import { catalogRoute } from '../routes'; -import { EntityPageApi } from './EntityPageApi'; -import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; - -const isPluginApplicableToEntity = (entity: Entity) => { - // TODO: Also support RELATION_CONSUMES_API - return entity.relations?.some(r => r.type === RELATION_PROVIDES_API); -}; - -export const Router = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( - - } - /> - ) - - ); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 398268caf2..a602877525 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -37,7 +37,7 @@ export const ApiExplorerPage = () => { variant="contained" color="primary" component={RouterLink} - to="/register-component" + to="/catalog-import" > Register Existing API diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx new file mode 100644 index 0000000000..7db62433eb --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiEntity } from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core'; +import React from 'react'; +import { ApiTypeTitle } from '../ApiDefinitionCard'; +import { EntityLink } from '../EntityLink'; + +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'metadata.name', + highlight: true, + render: (entity: any) => ( + {entity.metadata.name} + ), + }, + { + title: 'Owner', + field: 'spec.owner', + }, + { + title: 'Lifecycle', + field: 'spec.lifecycle', + }, + { + title: 'Type', + field: 'spec.type', + render: (entity: ApiEntity) => , + }, + { + title: 'Description', + field: 'metadata.description', + width: 'auto', + }, +]; + +type Props = { + title: string; + variant?: string; + entities: (ApiEntity | undefined)[]; +}; + +export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + return ( + + columns={columns} + title={title} + style={tableStyle} + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + // TODO: For now we skip all APIs that we can't find without a warning! + data={entities.filter(e => e !== undefined) as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx new file mode 100644 index 0000000000..a4a1e15511 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { ConsumedApisCard } from './ConsumedApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); + expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + }); + + it('shows consumed APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_CONSUMES_API, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + }); + apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ + type: 'openapi', + title: 'OpenAPI', + component: () =>
, + }); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(getByText(/OpenAPI/)).toBeInTheDocument(); + expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/production/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx new file mode 100644 index 0000000000..0bd4919554 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiEntity, + Entity, + RELATION_CONSUMES_API, +} from '@backstage/catalog-model'; +import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import React, { PropsWithChildren } from 'react'; +import { ApisTable } from './ApisTable'; +import { MissingConsumesApisEmptyState } from '../EmptyState'; +import { useRelatedEntities } from '../useRelatedEntities'; + +const ApisCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + entity: Entity; + variant?: string; +}; + +export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => { + const { entities, loading, error } = useRelatedEntities( + entity, + RELATION_CONSUMES_API, + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!entities || entities.length === 0) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx new file mode 100644 index 0000000000..1f42ff9060 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { ProvidedApisCard } from './ProvidedApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText(/Provided APIs/i)).toBeInTheDocument(); + expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + }); + + it('shows consumed APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_PROVIDES_API, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + }); + apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ + type: 'openapi', + title: 'OpenAPI', + component: () =>
, + }); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText(/Provided APIs/i)).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(getByText(/OpenAPI/)).toBeInTheDocument(); + expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/production/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx new file mode 100644 index 0000000000..618f2dc1f6 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiEntity, + Entity, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; +import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import React, { PropsWithChildren } from 'react'; +import { ApisTable } from './ApisTable'; +import { MissingProvidesApisEmptyState } from '../EmptyState'; +import { useRelatedEntities } from '../useRelatedEntities'; + +const ApisCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + entity: Entity; + variant?: string; +}; + +export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => { + const { entities, loading, error } = useRelatedEntities( + entity, + RELATION_PROVIDES_API, + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!entities || entities.length === 0) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/index.ts b/plugins/api-docs/src/components/ApisCards/index.ts new file mode 100644 index 0000000000..2a01a1dc6e --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConsumedApisCard } from './ConsumedApisCard'; +export { ProvidedApisCard } from './ProvidedApisCard'; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index 86f524d099..a908a91b14 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import AsyncApi from '@kyma-project/asyncapi-react'; +import AsyncApi from '@asyncapi/react-component'; +import '@asyncapi/react-component/lib/styles/fiori.css'; +import { fade, makeStyles } from '@material-ui/core/styles'; import React from 'react'; -import { makeStyles, fade } from '@material-ui/core/styles'; -import '@kyma-project/asyncapi-react/lib/styles/fiori.css'; const useStyles = makeStyles(theme => ({ root: { diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx new file mode 100644 index 0000000000..1b62a56d10 --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity } from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core'; +import React from 'react'; +import { EntityLink } from '../EntityLink'; + +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'metadata.name', + highlight: true, + render: (entity: any) => ( + {entity.metadata.name} + ), + }, + { + title: 'Owner', + field: 'spec.owner', + }, + { + title: 'Lifecycle', + field: 'spec.lifecycle', + }, + { + title: 'Type', + field: 'spec.type', + }, + { + title: 'Description', + field: 'metadata.description', + width: 'auto', + }, +]; + +type Props = { + title: string; + variant?: string; + entities: (ComponentEntity | undefined)[]; +}; + +// TODO: In theory this could also be systems! +export const ComponentsTable = ({ + entities, + title, + variant = 'gridItem', +}: Props) => { + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + return ( + + columns={columns} + title={title} + style={tableStyle} + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + // TODO: For now we skip all APIs that we can't find without a warning! + data={entities.filter(e => e !== undefined) as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx new file mode 100644 index 0000000000..606ff7e77b --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ConsumingComponentsCard } from './ConsumingComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText(/Consumers/i)).toBeInTheDocument(); + expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + }); + + it('shows consuming components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_API_CONSUMED_BY, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: { + type: 'service', + owner: 'Test', + lifecycle: 'production', + }, + }); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText(/Consumers/i)).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/production/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx new file mode 100644 index 0000000000..0431367aa2 --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + Entity, + RELATION_API_CONSUMED_BY, +} from '@backstage/catalog-model'; +import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import React, { PropsWithChildren } from 'react'; +import { MissingConsumesApisEmptyState } from '../EmptyState'; +import { useRelatedEntities } from '../useRelatedEntities'; +import { ComponentsTable } from './ComponentsTable'; + +const ComponentsCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + entity: Entity; + variant?: string; +}; + +export const ConsumingComponentsCard = ({ + entity, + variant = 'gridItem', +}: Props) => { + const { entities, loading, error } = useRelatedEntities( + entity, + RELATION_API_CONSUMED_BY, + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!entities || entities.length === 0) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx new file mode 100644 index 0000000000..d1cec1722a --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ProvidingComponentsCard } from './ProvidingComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText(/Providers/i)).toBeInTheDocument(); + expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + }); + + it('shows providing components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'my-name', + namespace: 'my-namespace', + }, + spec: { + type: 'openapi', + owner: 'Test', + lifecycle: 'production', + definition: '...', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_API_PROVIDED_BY, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: { + type: 'service', + owner: 'Test', + lifecycle: 'production', + }, + }); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(getByText(/Providers/i)).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/production/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx new file mode 100644 index 0000000000..9e405a3af3 --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentEntity, + Entity, + RELATION_API_PROVIDED_BY, +} from '@backstage/catalog-model'; +import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import React, { PropsWithChildren } from 'react'; +import { MissingProvidesApisEmptyState } from '../EmptyState'; +import { useRelatedEntities } from '../useRelatedEntities'; +import { ComponentsTable } from './ComponentsTable'; + +const ComponentsCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + entity: Entity; + variant?: string; +}; + +export const ProvidingComponentsCard = ({ + entity, + variant = 'gridItem', +}: Props) => { + const { entities, loading, error } = useRelatedEntities( + entity, + RELATION_API_PROVIDED_BY, + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!entities || entities.length === 0) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/ComponentsCards/index.ts b/plugins/api-docs/src/components/ComponentsCards/index.ts new file mode 100644 index 0000000000..e1c0e87198 --- /dev/null +++ b/plugins/api-docs/src/components/ComponentsCards/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConsumingComponentsCard } from './ConsumingComponentsCard'; +export { ProvidingComponentsCard } from './ProvidingComponentsCard'; diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx new file mode 100644 index 0000000000..de753713c3 --- /dev/null +++ b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/consumesApis:/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx new file mode 100644 index 0000000000..3e71168dde --- /dev/null +++ b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Button, makeStyles, Typography } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { CodeSnippet, EmptyState } from '@backstage/core'; + +const COMPONENT_YAML = `# Example +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example +spec: + type: service + lifecycle: production + owner: guest + consumesApis: + - example-api +`; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: `${theme.spacing(2)}px 0px`, + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, +})); + +export const MissingConsumesApisEmptyState = () => { + const classes = useStyles(); + return ( + + Components can consume APIs that are displayed on this page. You need + to fill the consumesApis field to enable this tool. + + } + action={ + <> + + Link an API to your component as shown in the highlighted example + below: + +
+ +
+ + + } + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx new file mode 100644 index 0000000000..b539753a95 --- /dev/null +++ b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/providesApis:/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx similarity index 95% rename from plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx rename to plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx index fbb8810088..9bf3465a34 100644 --- a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx +++ b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx @@ -40,12 +40,12 @@ const useStyles = makeStyles(theme => ({ }, })); -export const MissingImplementsApisEmptyState = () => { +export const MissingProvidesApisEmptyState = () => { const classes = useStyles(); return ( Components can implement APIs that are displayed on this page. You diff --git a/plugins/api-docs/src/components/EmptyState/index.ts b/plugins/api-docs/src/components/EmptyState/index.ts new file mode 100644 index 0000000000..d195c43eb1 --- /dev/null +++ b/plugins/api-docs/src/components/EmptyState/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; +export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; diff --git a/plugins/api-docs/src/components/index.ts b/plugins/api-docs/src/components/index.ts index cf9b189091..cfd985f47c 100644 --- a/plugins/api-docs/src/components/index.ts +++ b/plugins/api-docs/src/components/index.ts @@ -14,13 +14,9 @@ * limitations under the License. */ -export type { ApiDefinitionWidget } from './ApiDefinitionCard'; -export { - ApiDefinitionCard, - defaultDefinitionWidgets, -} from './ApiDefinitionCard'; -export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; -export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; -export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget'; -export { useComponentApiNames } from './useComponentApiNames'; -export { useComponentApiEntities } from './useComponentApiEntities'; +export * from './ApiDefinitionCard'; +export * from './ApisCards'; +export * from './AsyncApiDefinitionWidget'; +export * from './ComponentsCards'; +export * from './OpenApiDefinitionWidget'; +export * from './PlainApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/useComponentApiEntities.ts b/plugins/api-docs/src/components/useComponentApiEntities.ts deleted file mode 100644 index 9e5cbd968e..0000000000 --- a/plugins/api-docs/src/components/useComponentApiEntities.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useAsyncRetry } from 'react-use'; -import { errorApiRef, useApi } from '@backstage/core'; -import { - ApiEntity, - ComponentEntity, - parseEntityName, -} from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { useComponentApiNames } from './useComponentApiNames'; - -export function useComponentApiEntities({ - entity, -}: { - entity: ComponentEntity; -}): { - loading: boolean; - apiEntities?: Map; - error?: Error; - retry: () => void; -} { - const catalogApi = useApi(catalogApiRef); - const errorApi = useApi(errorApiRef); - - const apiNames = useComponentApiNames(entity); - - const { loading, value: apiEntities, retry, error } = useAsyncRetry< - Map - >(async () => { - const resultMap = new Map(); - - await Promise.all( - apiNames.map(async name => { - try { - const apiEntityName = parseEntityName(name, { - defaultNamespace: entity.metadata.namespace, - defaultKind: 'API', - }); - - if (apiEntityName.kind !== 'API') { - throw new Error( - `Referenced entity of kind "${apiEntityName.kind}" as an API`, - ); - } - - const api = (await catalogApi.getEntityByName(apiEntityName)) as - | ApiEntity - | undefined; - - if (api) { - resultMap.set(api.metadata.name, api); - } - } catch (e) { - errorApi.post(e); - } - }), - ); - - return resultMap; - }, [catalogApi, entity]); - - return { - apiEntities, - loading, - error, - retry, - }; -} diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/api-docs/src/components/useRelatedEntities.ts new file mode 100644 index 0000000000..847ec30578 --- /dev/null +++ b/plugins/api-docs/src/components/useRelatedEntities.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { useAsyncRetry } from 'react-use'; + +// TODO: Maybe this hook is interesting for others too? +export function useRelatedEntities( + entity: Entity, + type: string, +): { + entities: (Entity | undefined)[] | undefined; + loading: boolean; + error: Error | undefined; +} { + const catalogApi = useApi(catalogApiRef); + const { loading, value, error } = useAsyncRetry< + (Entity | undefined)[] + >(async () => { + const relations = + entity.relations && entity.relations.filter(r => r.type === type); + + if (!relations) { + return []; + } + + return await Promise.all( + relations?.map(r => catalogApi.getEntityByName(r.target)), + ); + }, [entity, type]); + + return { + entities: value, + loading, + error, + }; +} diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index dbb32cee7b..f09aeb1038 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export * from './catalog'; export * from './components'; export { plugin } from './plugin'; diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index 6adff78e47..64277b9ae8 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -23,9 +23,3 @@ export const rootRoute = createRouteRef({ path: '/api-docs', title: 'APIs', }); - -export const catalogRoute = createRouteRef({ - icon: NoIcon, - path: '', - title: 'API', -}); diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 1f2b3f008a..90a4eac95d 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,37 @@ # @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 + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [4e7091759] +- Updated dependencies [b4488ddb0] +- Updated dependencies [612368274] + - @backstage/config-loader@0.4.0 + - @backstage/backend-common@0.3.3 + ## 0.3.1 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index ac350a14b4..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.1", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/app-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.2", - "@backstage/config-loader": "^0.3.0", - "@backstage/config": "^0.1.1", + "@backstage/backend-common": "^0.5.0", + "@backstage/config-loader": "^0.4.0", + "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -31,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.3.2", + "@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 257c38a3e5..0df0fa1938 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,103 @@ # @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 + +- cc046682e: fix bug in token expiration date + +## 0.2.7 + +### Patch Changes + +- 7b15cc271: Added configuration schema for the commonly used properties +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + - @backstage/catalog-client@0.3.4 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + - @backstage/catalog-client@0.3.3 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + - @backstage/catalog-client@0.3.2 + ## 0.2.4 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index e69bb5b86b..1fea3346a7 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -10,7 +10,7 @@ to the appropriate provider in the backend. ## Local development Choose your OAuth Providers, replace `x` with actual value and then start backend: -Example for Google Oauth Provider at root directory: +Example for Google OAuth Provider at root directory: ```bash export AUTH_GOOGLE_CLIENT_ID=x @@ -25,7 +25,7 @@ export AUTH_GOOGLE_CLIENT_ID=x export AUTH_GOOGLE_CLIENT_SECRET=x ``` -### Github +### GitHub #### Creating a GitHub OAuth application @@ -42,7 +42,7 @@ export AUTH_GITHUB_CLIENT_ID=x export AUTH_GITHUB_CLIENT_SECRET=x ``` -for github enterprise: +For GitHub Enterprise: ```bash export AUTH_GITHUB_CLIENT_ID=x @@ -50,7 +50,7 @@ export AUTH_GITHUB_CLIENT_SECRET=x export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://x ``` -### Gitlab +### GitLab #### Creating a GitLab OAuth application @@ -70,7 +70,7 @@ Follow this link, [Add new application](https://gitlab.com/-/profile/application ```bash export GITLAB_BASE_URL=https://gitlab.com -export AUTH_GITLAB_CLIENT_ID=x # Gitlab calls this the Application ID +export AUTH_GITLAB_CLIENT_ID=x # GitLab calls this the Application ID export AUTH_GITLAB_CLIENT_SECRET=x ``` @@ -89,8 +89,16 @@ export AUTH_GITLAB_CLIENT_SECRET=x ### Okta +Add a new Okta application using the following URI conventions: + +Login redirect URI's: `http://localhost:7000/api/auth/okta/handler/frame` +Logout redirect URI's: `http://localhost:7000/api/auth/okta/logout` +Initiate login URI's: `http://localhost:7000/api/auth/okta/start` + +Then configure the following environment variables to be used in the `app-config.yaml` file: + ```bash -export AUTH_OKTA_AUDIENCE=x +export AUTH_OKTA_AUDIENCE=https://example.okta.com export AUTH_OKTA_CLIENT_ID=x export AUTH_OKTA_CLIENT_SECRET=x ``` diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts new file mode 100644 index 0000000000..c748090711 --- /dev/null +++ b/plugins/auth-backend/config.d.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** Configuration options for the auth plugin */ + auth?: { + /** + * The 'environment' attribute + * @visibility frontend + */ + environment?: string; + + session?: { + /** + * The secret attribute of session object. + * @visibility secret + */ + secret?: string; + }; + + /** + * The available auth-provider options and attributes + */ + providers?: { + google?: { + development: { [key: string]: string }; + }; + github?: { + development: { [key: string]: string }; + }; + gitlab?: { + development: { [key: string]: string }; + }; + saml?: { + entryPoint: string; + logoutUrl?: string; + issuer: string; + cert?: string; + privateKey?: string; + decryptionPvk?: string; + signatureAlgorithm?: 'sha256' | 'sha512'; + digestAlgorithm?: string; + }; + okta?: { + development: { [key: string]: string }; + }; + oauth2?: { + development: { [key: string]: string }; + }; + oidc?: { + development: { [key: string]: string }; + }; + auth0?: { + development: { [key: string]: string }; + }; + microsoft?: { + development: { [key: string]: string }; + }; + onelogin?: { + development: { [key: string]: string }; + }; + }; + }; +} diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 814c8c5258..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.4", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.2", - "@backstage/catalog-client": "^0.3.1", - "@backstage/catalog-model": "^0.3.1", - "@backstage/config": "^0.1.1", + "@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", "cookie-parser": "^1.4.5", @@ -36,10 +45,11 @@ "got": "^11.5.2", "helmet": "^4.0.0", "jose": "^1.27.1", - "jwt-decode": "2.2.0", + "jwt-decode": "^3.1.0", "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", @@ -49,50 +59,31 @@ "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.3.2", + "@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": "2.2.1", - "@types/nock": "^11.1.0", - "@types/openid-client": "^3.7.0", + "@types/jwt-decode": "^3.1.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" }, "files": [ "dist", - "migrations" + "migrations", + "config.d.ts" ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/auth-backend", - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "session": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "visibility": "secret" - } - } - } - } - } - } - } + "configSchema": "config.d.ts" } diff --git a/plugins/auth-backend/src/identity/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 4303401ee6..0c7febe616 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -14,43 +14,13 @@ * limitations under the License. */ -import { utc } from 'moment'; +import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; import { getVoidLogger } from '@backstage/backend-common'; -import { KeyStore, AnyJWK, StoredKey } from './types'; import { JWKS, JSONWebKey, JWT } from 'jose'; const logger = getVoidLogger(); -class MemoryKeyStore implements KeyStore { - private readonly keys = new Map< - string, - { createdAt: moment.Moment; key: string } - >(); - - async addKey(key: AnyJWK): Promise { - this.keys.set(key.kid, { - createdAt: utc(), - key: JSON.stringify(key), - }); - } - - async removeKeys(kids: string[]): Promise { - for (const kid of kids) { - this.keys.delete(kid); - } - } - - async listKeys(): Promise<{ items: StoredKey[] }> { - return { - items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ - createdAt, - key: JSON.parse(keyStr), - })), - }; - } -} - function jwtKid(jwt: string): string { const { header } = JWT.decode(jwt, { complete: true }) as { header: { kid: string }; @@ -87,7 +57,7 @@ describe('TokenFactory', () => { iat: expect.any(Number), exp: expect.any(Number), }); - expect(payload.exp).toBe(payload.iat + keyDurationSeconds * 1000); + expect(payload.exp).toBe(payload.iat + keyDurationSeconds); }); it('should generate new signing keys when the current one expires', async () => { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index c4c259ed22..36622332e0 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -69,7 +69,7 @@ export class TokenFactory implements TokenIssuer { const sub = params.claims.sub; const aud = 'backstage'; const iat = Math.floor(Date.now() / MS_IN_S); - const exp = iat + this.keyDurationSeconds * MS_IN_S; + const exp = iat + this.keyDurationSeconds; this.logger.info(`Issuing token for ${sub}`); 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/lib/flow/authFlowHelpers.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts index 22ffc7af88..e70aa0bfee 100644 --- a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts @@ -56,7 +56,9 @@ export const postMessageResponse = ( var originInfo = {'type': 'config_info', 'targetOrigin': origin}; (window.opener || window.parent).postMessage(originInfo, '*'); (window.opener || window.parent).postMessage(JSON.parse(authResponse), origin); - window.close(); + setTimeout(() => { + window.close(); + }, 100); // same as the interval of the core-api lib/loginPopup.ts (to address race conditions) `; const hash = crypto.createHash('sha256').update(script).digest('base64'); diff --git a/plugins/auth-backend/src/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 64f1a7ba87..77620c1cea 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,201 @@ # @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 + +- 5de26b9a6: Start warning about usage of deprecated location types, such as `github` +- 30d6c78fb: Added configuration schema for the commonly used properties +- 5084e5039: Updated the config schema + +## 0.5.0 + +### Minor Changes + +- 6b37c95bf: Write relations directly as part of batch add / update of entities. + + Slight change of the `CommonDatabase` contract: + + ## `addEntity` removed + + This method was unused by the core, and rendered unnecessary when `addEntities` + exists. + + If you were a user of `addEntity`, please call `addEntities` instead, with an + array of one element. + + ## `DbEntityRequest` has a new field `relations` + + This is the structure that is passed to `addEntities` and `updateEntity`. It + used to be the case that you needed to call `setRelations` separately, but now + this instead happens directly when you call `addEntities` or `updateEntity`. + + If you were using `addEntities` or `updateEntity` directly, please adapt your + code to add the `relations` array to each request. If you were calling + `setRelations` separately next to these methods, you no longer need to do so, + after adding the relations to the `DbEntityRequest`s. + +- ac3560b42: Remove `implementsApis` from `Component` entities. Deprecation happened in [#3449](https://github.com/backstage/backstage/pull/3449). + Use `providesApis` instead. + +### Patch Changes + +- c6eeefa35: Add support for GitHub Enterprise in GitHubOrgReaderProcessor so you can properly ingest users of a GHE organization. +- fb386b760: Break the refresh loop into several smaller transactions +- 7c3ffc0cd: Support `profile` of groups including `displayName`, `email`, and `picture` in + `LdapOrgReaderProcessor`. The source fields for them can be configured in the + `ldapOrg` provider. +- e7496dc3e: Break out GithubOrgReaderProcessor config into its own file for consistency with the other org processors. +- 8dd0a906d: Support `profile` of groups including `displayName` and `picture` in + `GithubOrgReaderProcessor`. Fixes the import of `description` for groups. +- 8c31c681c: Batch the writing of statuses after refreshes. This reduced the runtime on sqlite from 16s to 0.2s, and on pg from 60s to 1s on my machine, for the huge LDAP set. +- 7b98e7fee: Add index to foreign key columns. Postgres (and others) do not do this on the "source" side of a foreign key relation, which was what led to the slowness on large datasets. The full LDAP dataset ingestion now takes two minutes, which is not optimal yet but still a huge improvement over before when it basically never finished :) +- 0097057ed: Support `profile` of groups including `displayName` and `email` in + `MicrosoftGraphOrgReaderProcessor`. Importing `picture` doesn't work yet, as + the Microsoft Graph API does not expose them correctly. +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.4.0 + +### Minor Changes + +- 83b6e0c1f: Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity. + + See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details. + +### Patch Changes + +- 6e8bb3ac0: leave unknown placeholder-lookalikes untouched in the catalog processing loop +- e708679d7: refreshAllLocations uses a child logger of the HigherOrderOperation with a meta `component` : `catalog-all-locations-refresh` +- 047c018c9: Batch the fetching of relations +- 38d63fbe1: Fix string template literal +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.3.0 + +### Minor Changes + +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError +- 08835a61d: Add support for relative targets and implicit types in Location entities. +- e42402b47: Gracefully handle missing codeowners. + + The CodeOwnersProcessor now also takes a logger as a parameter. + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + ## 0.2.3 ### Patch Changes diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts new file mode 100644 index 0000000000..7877f34dcf --- /dev/null +++ b/plugins/catalog-backend/config.d.ts @@ -0,0 +1,386 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonValue } from '@backstage/config'; + +export interface Config { + /** + * Configuration options for the catalog plugin. + */ + catalog?: { + /** + * Rules to apply to all catalog entities, from any location. + * + * An undefined list of matchers means match all, an empty list of + * matchers means match none. + * + * This is commonly used to put in what amounts to a whitelist of kinds + * that regular users of Backstage are permitted to register locations + * for. This can be used to stop them from registering yaml files + * describing for example a Group entity called "admin" that they make + * themselves members of, or similar. + */ + rules?: Array<{ + /** + * Allow entities of these particular kinds. + * + * E.g. ["Component", "API", "Template", "Location"] + */ + allow: Array; + }>; + + /** + * A set of static locations that the catalog shall always keep itself + * up-to-date with. This is commonly used for large, permanent integrations + * that are defined by the Backstage operators at an organization, rather + * than individual things that users register dynamically. + * + * These have (optional) rules of their own. These override what the global + * rules above specify. This way, you can prevent everybody from register + * e.g. User and Group entities, except for one or a few static locations + * that have those two kinds explicitly allowed. + * + * For example: + * + * ```yaml + * rules: + * - allow: [Component, API, Template, Location] + * locations: + * - type: url + * target: https://github.com/org/repo/blob/master/users.yaml + * rules: + * - allow: [User, Group] + * - type: url + * target: https://github.com/org/repo/blob/master/systems.yaml + * rules: + * - allow: [System] + * ``` + */ + locations?: Array<{ + /** + * The type of location, e.g. "url". + */ + type: string; + /** + * The target URL of the location, e.g. + * "https://github.com/org/repo/blob/master/users.yaml". + */ + target: string; + /** + * Optional extra rules that apply to this particular location. + * + * These override the global rules above. + */ + rules?: Array<{ + /** + * Allow entities of these particular kinds. + * + * E.g. ["Group", "User"] + */ + allow: Array; + }>; + }>; + + /** + * List of processor-specific options and attributes + */ + processors?: { + /** + * GithubOrgReaderProcessor configuration + */ + githubOrg?: { + /** + * The configuration parameters for each single GitHub org provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "https://github.com", with no trailing slash. + */ + target: string; + /** + * The base URL of the API of this provider, e.g. + * "https://api.github.com", with no trailing slash. + * + * May be omitted specifically for GitHub; then it will be deduced. + */ + apiBaseUrl?: string; + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous access is used. + * + * @visibility secret + */ + token?: string; + }>; + }; + + /** + * LdapOrgReaderProcessor configuration + */ + ldapOrg?: { + /** + * The configuration parameters for each single LDAP provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "ldaps://ds.example.net", with no trailing slash. + */ + target: string; + + /** + * The settings to use for the bind command. If none are specified, + * the bind command is not issued. + */ + bind?: { + /** + * The DN of the user to auth as. + * + * E.g. "uid=ldap-robot,ou=robots,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The secret of the user to auth as (its password). + * + * @visibility secret + */ + secret: string; + }; + + /** + * The settings that govern the reading and interpretation of users. + */ + users: { + /** + * The DN under which users are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "uid". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "uid". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. Defaults to + * "mail". + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.memberOf field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + }; + }; + + /** + * The settings that govern the reading and interpretation of groups. + */ + groups: { + /** + * The DN under which groups are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "cn". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "cn". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. Defaults to + * "description". + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.type field of the entity. Defaults to "groupType". + */ + type?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.parent field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.children field of the entity. Defaults to "member". + */ + members?: string; + }; + }; + }>; + }; + + /** + * MicrosoftGraphOrgReaderProcessor configuration + */ + microsoftGraphOrg?: { + /** + * The configuration parameters for each single Microsoft Graph provider. + */ + providers: Array<{ + /** + * The prefix of the target that this matches on, e.g. + * "https://graph.microsoft.com/v1.0", with no trailing slash. + */ + target: string; + /** + * The auth authority used. + * + * Default value "https://login.microsoftonline.com" + */ + authority?: string; + /** + * The tenant whose org data we are interested in. + */ + tenantId: string; + /** + * The OAuth client ID to use for authenticating requests. + */ + clientId: string; + /** + * The OAuth client secret to use for authenticating requests. + * + * @visibility secret + */ + clientSecret: string; + /** + * The filter to apply to extract users. + * + * E.g. "accountEnabled eq true and userType eq 'member'" + */ + userFilter?: string; + /** + * The filter to apply to extract groups. + * + * E.g. "securityEnabled eq false and mailEnabled eq true" + */ + groupFilter?: string; + }>; + }; + }; + }; +} diff --git a/plugins/catalog-backend/migrations/20200702153613_entities.js b/plugins/catalog-backend/migrations/20200702153613_entities.js index c97331796d..9acd7564db 100644 --- a/plugins/catalog-backend/migrations/20200702153613_entities.js +++ b/plugins/catalog-backend/migrations/20200702153613_entities.js @@ -20,16 +20,14 @@ * @param {import('knex')} knex */ exports.up = async function up(knex) { - // Drop constraints (Postgres) - try { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.dropForeign(['entity_id']); }); await knex.schema.alterTable('entities', table => { table.dropPrimary('entities_pkey'); }); - } catch (e) { - // SQLite does not support FK and PK, carry on } await knex.schema.alterTable('entities', table => { table.dropUnique([], 'entities_unique_name'); @@ -131,16 +129,14 @@ exports.up = async function up(knex) { * @param {import('knex')} knex */ exports.down = async function down(knex) { - // Drop constraints (Postgres) - try { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.dropForeign(['entity_id']); }); await knex.schema.alterTable('entities', table => { table.dropPrimary('entities_pkey'); }); - } catch (e) { - // SQLite does not support FK and PK, carry on } await knex.schema.alterTable('entities', table => { table.dropUnique([], 'entities_unique_name'); diff --git a/plugins/catalog-backend/migrations/20200807120600_entitySearch.js b/plugins/catalog-backend/migrations/20200807120600_entitySearch.js index 6e02975f92..b3a9673641 100644 --- a/plugins/catalog-backend/migrations/20200807120600_entitySearch.js +++ b/plugins/catalog-backend/migrations/20200807120600_entitySearch.js @@ -20,12 +20,11 @@ * @param {import('knex')} knex */ exports.up = async function up(knex) { - try { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.text('value').nullable().alter(); }); - } catch (e) { - // Sqlite does not support alter column. } }; @@ -33,11 +32,10 @@ exports.up = async function up(knex) { * @param {import('knex')} knex */ exports.down = async function down(knex) { - try { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities_search', table => { table.string('value').nullable().alter(); }); - } catch (e) { - // Sqlite does not support alter column. } }; diff --git a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js index 26cc98e74e..4c1ea76de4 100644 --- a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js +++ b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js @@ -30,12 +30,11 @@ exports.up = async function up(knex) { ), }); - try { + // SQLite does not support alter column + if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities', table => { table.text('full_name').notNullable().alter(); }); - } catch (e) { - // SQLite does not support alter column, ignore } await knex.schema.alterTable('entities', table => { diff --git a/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js index fdabf72fce..a326a08a8b 100644 --- a/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js +++ b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js @@ -40,10 +40,7 @@ exports.up = async function up(knex) { table.dropColumn('spec'); }); - // SQLite does not support ALTER COLUMN. Note that we do not use the try/ - // catch method as in other migrations, because if the transaction is - // partially failed, it will further mess up the already messed-up - // statement below this. + // SQLite does not support ALTER COLUMN. if (knex.client.config.client !== 'sqlite3') { await knex.schema.alterTable('entities', table => { table.text('data').notNullable().alter(); diff --git a/plugins/catalog-backend/migrations/20201210185851_fk_index.js b/plugins/catalog-backend/migrations/20201210185851_fk_index.js new file mode 100644 index 0000000000..acf90b0a27 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201210185851_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js b/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js new file mode 100644 index 0000000000..71f9500cf2 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201230103504_update_log_varchar.js @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + // We actually just want to widen columns, but can't do that while a + // view is dependent on them - so we just reconstruct it exactly as it was + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.text('message').alter(); + table.text('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.string('message').alter(); + table.string('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 9a4933ea6e..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.2.3", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-backend" + }, + "keywords": [ + "backstage" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,11 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.3.2", - "@backstage/catalog-model": "^0.3.1", - "@backstage/config": "^0.1.1", - "@octokit/graphql": "^4.5.6", + "@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.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", "codeowners-utils": "^1.0.2", @@ -33,14 +43,13 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.0", + "git-url-parse": "^11.4.3", "knex": "^0.21.6", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", "p-limit": "^3.0.2", "qs": "^6.9.4", - "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -48,8 +57,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@backstage/test-utils": "^0.1.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", "@types/lodash": "^4.14.151", @@ -57,10 +66,13 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.21.2", + "sqlite3": "^5.0.0", "supertest": "^4.0.2" }, "files": [ "dist", - "migrations/**/*.{js,d.ts}" - ] + "migrations/**/*.{js,d.ts}", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 8d2138b3e5..5059d741f4 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -80,9 +80,10 @@ describe('DatabaseEntitiesCatalog', () => { 'kind=b,metadata.namespace=d,metadata.name=c', ), ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -113,9 +114,10 @@ describe('DatabaseEntitiesCatalog', () => { 'kind=b,metadata.namespace=d,metadata.name=c', ), ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(db.addEntities).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ + { entity: expect.anything(), relations: [] }, + ]); expect(transaction.rollback).toBeCalledTimes(1); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -145,11 +147,7 @@ describe('DatabaseEntitiesCatalog', () => { }, }, }; - db.entities.mockResolvedValue([ - { - entity: dbEntity, - }, - ]); + db.entities.mockResolvedValue([{ entity: dbEntity }]); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -161,7 +159,7 @@ describe('DatabaseEntitiesCatalog', () => { ); expect(db.entities).toHaveBeenCalledTimes(2); - expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledTimes(1); expect(result).toEqual([ { entityId: 'u', @@ -237,12 +235,11 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, ); - expect(db.setRelations).toHaveBeenCalledTimes(1); - expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(result).toEqual([{ entityId: 'u' }]); }); @@ -315,6 +312,7 @@ describe('DatabaseEntitiesCatalog', () => { x: 'b', }, }, + relations: [], }, 'e', 1, diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index f40e327eaa..76fc63c9b3 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { ConflictError, NotFoundError } from '@backstage/backend-common'; import { Entity, entityHasChanges, - EntityRelationSpec, generateUpdatedEntity, getEntityName, LOCATION_ANNOTATION, @@ -73,38 +72,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return items.map(i => i.entity); } - private async addOrUpdateEntity( - entity: Entity, - tx: Transaction, - locationId?: string, - ): Promise { - // Find a matching (by uid, or by compound name, depending on the given - // entity) existing entity, to know whether to update or add - const existing = entity.metadata.uid - ? await this.database.entityByUid(tx, entity.metadata.uid) - : await this.database.entityByName(tx, getEntityName(entity)); - - // If it's an update, run the algorithm for annotation merging, updating - // etag/generation, etc. - let response: DbEntityResponse; - if (existing) { - const updated = generateUpdatedEntity(existing.entity, entity); - response = await this.database.updateEntity( - tx, - { locationId, entity: updated }, - existing.entity.metadata.etag, - existing.entity.metadata.generation, - ); - } else { - const added = await this.database.addEntities(tx, [ - { locationId, entity }, - ]); - response = added[0]; - } - - return response.entity; - } - async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { const entityResponse = await this.database.entityByUid(tx, uid); @@ -135,13 +102,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }); } - /** - * Writes a number of entities efficiently to storage. - * - * @param entities Some entities - * @param options.locationId The location that they all belong to - * @param options.tx A database transaction to execute the queries in - */ async batchAddOrUpdateEntities( requests: EntityUpsertRequest[], options?: { @@ -150,122 +110,125 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { outputEntities?: boolean; }, ): Promise { - const locationId = options?.locationId; + // Group the requests by unique kind+namespace combinations. The reason for + // this is that the change detection and merging logic requires finding + // pre-existing versions of the entities in the database. Those queries are + // easier and faster to make if every batch revolves around a single kind- + // namespace pair. + const requestsByKindAndNamespace = groupBy(requests, ({ entity }) => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); - return await this.database.transaction(async tx => { - // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { - const name = getEntityName(entity); - return `${name.kind}:${name.namespace}`.toLowerCase(); - }); + // Go through the requests in reasonable batch sizes. Sometimes, sources + // produce tens of thousands of entities, and those are too large batch + // sizes to reasonably send to the database. + const batches = Object.values(requestsByKindAndNamespace) + .map(requests => chunk(requests, BATCH_SIZE)) + .flat(); - const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; - - for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupRequests[0].entity); - - // Go through the new entities in reasonable chunk sizes (sometimes, - // sources produce tens of thousands of entities, and those are too large - // batch sizes to reasonably send to the database) - for (const batch of chunk(groupRequests, BATCH_SIZE)) { - tasks.push( - limiter(async () => { - const first = serializeEntityRef(batch[0].entity); - const last = serializeEntityRef(batch[batch.length - 1].entity); - let modifiedEntityIds: EntityUpsertResponse[] = []; - - this.logger.debug( - `Considering batch ${first}-${last} (${batch.length} entries)`, + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + const tasks = batches.map(batch => + limiter(async () => { + // Retry the batch write a few times to deal with contention + for (let attempt = 1; ; ++attempt) { + try { + return this.batchAddOrUpdateEntitiesSingleBatch(batch, options); + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, ); + } else { + throw e; + } + } + } + }), + ); - // Retry the batch write a few times to deal with contention - const context = { - kind, - namespace, - locationId, - }; - for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { - try { - const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( - batch, - context, - tx, - ); - if (toAdd.length) { - modifiedEntityIds.push( - ...(await this.batchAdd(toAdd, context, tx)), - ); - } - if (toUpdate.length) { - modifiedEntityIds.push( - ...(await this.batchUpdate(toUpdate, context, tx)), - ); - } - // TODO(Rugvip): We currently always update relations, but we - // likely want to figure out a way to avoid that - for (const { entity, relations } of toIgnore) { - const entityId = entity.metadata.uid; - if (entityId) { - await this.setRelations(entityId, relations, tx); - modifiedEntityIds.push({ entityId }); - } - } + const responses = await Promise.all(tasks); + return responses.flat(); + } - break; - } catch (e) { - if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { - this.logger.warn( - `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, - ); - } else { - throw e; - } - } - } + // Defines the actual logic of running a single batch. All of these share a + // common kind and namespace. + private async batchAddOrUpdateEntitiesSingleBatch( + batch: EntityUpsertRequest[], + options?: { + locationId?: string; + dryRun?: boolean; + outputEntities?: boolean; + }, + ) { + const { kind, namespace } = getEntityName(batch[0].entity); + const context = { + kind, + namespace, + locationId: options?.locationId, + }; - if (options?.outputEntities) { - const writtenEntities = await this.database.entities( - tx, - EntityFilters.ofMatchers({ - 'metadata.uid': modifiedEntityIds.map(e => e.entityId), - }), - ); + this.logger.debug( + `Considering batch ${serializeEntityRef( + batch[0].entity, + )}-${serializeEntityRef(batch[batch.length - 1].entity)} (${ + batch.length + } entries)`, + ); - modifiedEntityIds = writtenEntities.map(e => ({ - entityId: e.entity.metadata.uid!, - entity: e.entity, - })); - } + return this.database.transaction(async tx => { + const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( + batch, + context, + tx, + ); - return modifiedEntityIds; - }), - ); + let responses = new Array(); + if (toAdd.length) { + const items = await this.batchAdd(toAdd, context, tx); + responses.push(...items); + } + if (toUpdate.length) { + const items = await this.batchUpdate(toUpdate, context, tx); + responses.push(...items); + } + for (const { entity, relations } of toIgnore) { + // TODO(Rugvip): We currently always update relations, but we + // likely want to figure out a way to avoid that + const entityId = entity.metadata.uid; + if (entityId) { + await this.database.setRelations(tx, entityId, relations); + responses.push({ entityId }); } } - const entityUpserts = (await Promise.all(tasks)).flat(); + if (options?.outputEntities && responses.length > 0) { + const writtenEntities = await this.database.entities( + tx, + EntityFilters.ofMatchers({ + 'metadata.uid': responses.map(e => e.entityId), + }), + ); + responses = writtenEntities.map(e => ({ + entityId: e.entity.metadata.uid!, + entity: e.entity, + })); + } + // If this is only a dry run, cancel the database transaction even if it + // was successful. if (options?.dryRun) { - // If this is only a dry run, cancel the database transaction even if it was successful. await tx.rollback(); - this.logger.debug(`Performed successful dry run of adding entities`); } - return entityUpserts; + return responses; }); } - // Set the relations originating from an entity using the DB layer - private async setRelations( - originatingEntityId: string, - relations: EntityRelationSpec[], - tx: Transaction, - ): Promise { - await this.database.setRelations(tx, originatingEntityId, relations); - } - // Given a batch of entities that were just read from a location, take them // into consideration by comparing against the existing catalog entities and // produce the list of entities to be added, and the list of entities to be @@ -281,6 +244,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }> { const markTimestamp = process.hrtime(); + // Here we make use of the fact that all of the entities share kind and + // namespace within a batch const names = requests.map(({ entity }) => entity.metadata.name); const oldEntities = await this.database.entities( tx, @@ -320,7 +285,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // instead and call a dedicated batch update database method toUpdate.push(request); } else { - toIgnore.push(request); + // Use the existing entity to ensure that we're able to read it back by uid if needed + toIgnore.push({ ...request, entity: oldEntity }); } } @@ -344,22 +310,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const res = await this.database.addEntities( tx, - requests.map(({ entity }) => ({ locationId, entity })), + requests.map(({ entity, relations }) => ({ + locationId, + entity, + relations, + })), ); - const entityIds = res.map(({ entity }) => ({ + const responses = res.map(({ entity }) => ({ entityId: entity.metadata.uid!, })); - for (const [index, { entityId }] of entityIds.entries()) { - await this.setRelations(entityId, requests[index].relations, tx); - } - this.logger.debug( `Added ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return entityIds; + return responses; } // Efficiently updates the given entities into storage, under the assumption @@ -370,19 +336,52 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { tx: Transaction, ): Promise { const markTimestamp = process.hrtime(); - const responseIds: EntityUpsertResponse[] = []; + const responses: EntityUpsertResponse[] = []; + // TODO(freben): Still not batched - for (const entity of requests) { - const res = await this.addOrUpdateEntity(entity.entity, tx, locationId); + for (const request of requests) { + const res = await this.addOrUpdateEntity(tx, request, locationId); const entityId = res.metadata.uid!; - responseIds.push({ entityId }); - await this.setRelations(entityId, entity.relations, tx); + responses.push({ entityId }); } this.logger.debug( `Updated ${requests.length} entities in ${durationText(markTimestamp)}`, ); - return responseIds; + return responses; + } + + // TODO(freben): Incorporate this into batchUpdate which is the only caller + private async addOrUpdateEntity( + tx: Transaction, + { entity, relations }: EntityUpsertRequest, + locationId?: string, + ): Promise { + // Find a matching (by uid, or by compound name, depending on the given + // entity) existing entity, to know whether to update or add + const existing = entity.metadata.uid + ? await this.database.entityByUid(tx, entity.metadata.uid) + : await this.database.entityByName(tx, getEntityName(entity)); + + // If it's an update, run the algorithm for annotation merging, updating + // etag/generation, etc. + let response: DbEntityResponse; + if (existing) { + const updated = generateUpdatedEntity(existing.entity, entity); + response = await this.database.updateEntity( + tx, + { locationId, entity: updated, relations }, + existing.entity.metadata.etag, + existing.entity.metadata.generation, + ); + } else { + const added = await this.database.addEntities(tx, [ + { locationId, entity, relations }, + ]); + response = added[0]; + } + + return response.entity; } } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index 9ce53d2850..91245c0789 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -70,7 +70,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { async logUpdateSuccess( locationId: string, - entityName?: string, + entityName?: string | string[], ): Promise { await this.database.addLocationUpdateLogEvent( locationId, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 52bb6e95ab..b012ee3606 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -32,17 +32,30 @@ export type EntityUpsertResponse = { }; export type EntitiesCatalog = { + /** + * Fetch entities. + * + * @param filter A filter to apply when reading + */ entities(filter?: EntityFilter): Promise; + + /** + * Removes a single entity. + * + * @param uid The metadata.uid of the entity + */ removeEntityByUid(uid: string): Promise; /** * Writes a number of entities efficiently to storage. * - * @param entities Some entities - * @param locationId The location that they all belong to + * @param requests The entities and their relations + * @param options.locationId The location that they all belong to (default none) + * @param options.dryRun Whether to throw away the results (default false) + * @param options.outputEntities Whether to return the resulting entities (default false) */ batchAddOrUpdateEntities( - entities: EntityUpsertRequest[], + requests: EntityUpsertRequest[], options?: { locationId?: string; dryRun?: boolean; @@ -80,7 +93,10 @@ export type LocationsCatalog = { locations(): Promise; location(id: string): Promise; locationHistory(id: string): Promise; - logUpdateSuccess(locationId: string, entityName?: string): Promise; + logUpdateSuccess( + locationId: string, + entityName?: string | string[], + ): Promise; logUpdateFailure( locationId: string, error?: Error, diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index ffffd85f04..b319bb832d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -54,6 +54,7 @@ describe('CommonDatabase', () => { }, spec: { i: 'j' }, }, + relations: [], }; entityResponse = { @@ -136,6 +137,22 @@ describe('CommonDatabase', () => { await expect(db.location(location.id)).rejects.toThrow(/Found no location/); }); + it('refuses to remove the bootstrap location', async () => { + const input: Location = { + id: 'dd12620d-0436-422f-93bd-929aa0788123', + type: 'bootstrap', + target: 'bootstrap', + }; + + const output = await db.transaction( + async tx => await db.addLocation(tx, input), + ); + + await expect( + db.transaction(async tx => await db.removeLocation(tx, output.id)), + ).rejects.toThrow(ConflictError); + }); + describe('addEntities', () => { it('happy path: adds entities to empty database', async () => { const result = await db.transaction(tx => @@ -152,6 +169,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -159,6 +177,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, ]; await expect( @@ -174,6 +193,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -181,6 +201,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'nS1' }, }, + relations: [], }, ]; await expect( @@ -196,6 +217,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns1' }, }, + relations: [], }, { entity: { @@ -203,6 +225,7 @@ describe('CommonDatabase', () => { kind: 'k1', metadata: { name: 'n1', namespace: 'ns2' }, }, + relations: [], }, ]; await expect( @@ -285,7 +308,7 @@ describe('CommonDatabase', () => { db.addEntities(tx, [entityRequest]), ); const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion); expect(updated.entity.kind).toEqual(added.entity.kind); @@ -305,7 +328,7 @@ describe('CommonDatabase', () => { ); added.entity.metadata.name! = 'new!'; const updated = await db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }), + db.updateEntity(tx, { entity: added.entity, relations: [] }), ); expect(updated.entity.metadata.name).toEqual('new!'); }); @@ -316,7 +339,11 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, 'garbage'), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + 'garbage', + ), ), ).rejects.toThrow(ConflictError); }); @@ -327,7 +354,12 @@ describe('CommonDatabase', () => { ); await expect( db.transaction(tx => - db.updateEntity(tx, { entity: added.entity }, undefined, 1e20), + db.updateEntity( + tx, + { entity: added.entity, relations: [] }, + undefined, + 1e20, + ), ), ).rejects.toThrow(ConflictError); }); @@ -347,7 +379,10 @@ describe('CommonDatabase', () => { spec: { c: null }, }; await db.transaction(async tx => { - await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]); + await db.addEntities(tx, [ + { entity: e1, relations: [] }, + { entity: e2, relations: [] }, + ]); }); const result = await db.transaction(async tx => db.entities(tx)); expect(result.length).toEqual(2); @@ -385,7 +420,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -426,7 +461,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); @@ -566,8 +601,8 @@ describe('CommonDatabase', () => { const { id2: secondEntityId } = await db.transaction(async tx => { const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [ - { entity: entity1 }, - { entity: entity2 }, + { entity: entity1, relations: [] }, + { entity: entity2, relations: [] }, ]); const id1 = e1?.metadata?.uid!; const id2 = e2?.metadata?.uid!; @@ -665,7 +700,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities( tx, - entities.map(entity => ({ entity })), + entities.map(entity => ({ entity, relations: [] })), ); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 287756b0a8..2edc0ca5b8 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -95,35 +95,6 @@ export class CommonDatabase implements Database { } } - async addEntity( - txOpaque: Transaction, - request: DbEntityRequest, - ): Promise { - const tx = txOpaque as Knex.Transaction; - - if (request.entity.metadata.uid !== undefined) { - throw new InputError('May not specify uid for new entities'); - } else if (request.entity.metadata.etag !== undefined) { - throw new InputError('May not specify etag for new entities'); - } else if (request.entity.metadata.generation !== undefined) { - throw new InputError('May not specify generation for new entities'); - } - - const newEntity = lodash.cloneDeep(request.entity); - newEntity.metadata = { - ...newEntity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, - }; - - const newRow = this.toEntityRow(request.locationId, newEntity); - await tx('entities').insert(newRow); - await this.updateEntitiesSearch(tx, newRow.id, newEntity); - - return { locationId: request.locationId, entity: newEntity }; - } - async addEntities( txOpaque: Transaction, request: DbEntityRequest[], @@ -132,9 +103,10 @@ export class CommonDatabase implements Database { const result: DbEntityResponse[] = []; const entityRows: DbEntitiesRow[] = []; + const relationRows: DbEntitiesRelationsRow[] = []; const searchRows: DbEntitiesSearchRow[] = []; - for (const { entity, locationId } of request) { + for (const { entity, relations, locationId } of request) { if (entity.metadata.uid !== undefined) { throw new InputError('May not specify uid for new entities'); } else if (entity.metadata.etag !== undefined) { @@ -145,28 +117,27 @@ export class CommonDatabase implements Database { throw new InputError('May not specify relations for new entities'); } + const uid = generateEntityUid(); + const etag = generateEntityEtag(); + const generation = 1; const newEntity = { ...entity, metadata: { ...entity.metadata, - uid: generateEntityUid(), - etag: generateEntityEtag(), - generation: 1, + uid, + etag, + generation, }, }; result.push({ entity: newEntity, locationId }); entityRows.push(this.toEntityRow(locationId, newEntity)); - searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity)); + relationRows.push(...this.toRelationRows(uid, relations)); + searchRows.push(...buildEntitySearch(uid, newEntity)); } await tx.batchInsert('entities', entityRows, BATCH_SIZE); - await tx('entities_search') - .whereIn( - 'entity_id', - entityRows.map(r => r.id), - ) - .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); await tx.batchInsert('entities_search', searchRows, BATCH_SIZE); return result; @@ -181,8 +152,7 @@ export class CommonDatabase implements Database { const tx = txOpaque as Knex.Transaction; const { uid } = request.entity.metadata; - - if (uid === undefined) { + if (!uid) { throw new InputError('Must specify uid when updating entities'); } @@ -193,40 +163,48 @@ export class CommonDatabase implements Database { if (oldRows.length !== 1) { throw new NotFoundError('No matching entity found'); } + const etag = oldRows[0].etag; + const generation = Number(oldRows[0].generation); - // Validate the old entity - const oldRow = oldRows[0]; - // The Number cast is here because sqlite reads it as a string, no matter - // what the table actually says - oldRow.generation = Number(oldRow.generation); - if (matchingEtag) { - if (matchingEtag !== oldRow.etag) { - throw new ConflictError( - `Etag mismatch, expected="${matchingEtag}" found="${oldRow.etag}"`, - ); - } + // Validate the old entity. The Number cast is here because sqlite reads it + // as a string, no matter what the table actually says. + if (matchingEtag && matchingEtag !== etag) { + throw new ConflictError( + `Etag mismatch, expected="${matchingEtag}" found="${etag}"`, + ); } - if (matchingGeneration) { - if (matchingGeneration !== oldRow.generation) { - throw new ConflictError( - `Generation mismatch, expected="${matchingGeneration}" found="${oldRow.generation}"`, - ); - } + if (matchingGeneration && matchingGeneration !== generation) { + throw new ConflictError( + `Generation mismatch, expected="${matchingGeneration}" found="${generation}"`, + ); } // Store the updated entity; select on the old etag to ensure that we do // not lose to another writer const newRow = this.toEntityRow(request.locationId, request.entity); const updatedRows = await tx('entities') - .where({ id: oldRow.id, etag: oldRow.etag }) + .where({ id: uid, etag }) .update(newRow); - - // If this happens, somebody else changed the entity just now if (updatedRows !== 1) { throw new ConflictError(`Failed to update entity`); } - await this.updateEntitiesSearch(tx, oldRow.id, request.entity); + const relationRows = this.toRelationRows(uid, request.relations); + await tx('entities_relations') + .where({ originating_entity_id: uid }) + .del(); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); + + try { + const entries = buildEntitySearch(uid, request.entity); + await tx('entities_search') + .where({ entity_id: uid }) + .del(); + await tx.batchInsert('entities_search', entries, BATCH_SIZE); + } catch { + // ignore intentionally - if this happens, the entity was deleted before + // we got around to writing the entries + } return request; } @@ -271,7 +249,7 @@ export class CommonDatabase implements Database { .select('entities.*') .orderBy('full_name', 'asc'); - return Promise.all(rows.map(row => this.toEntityResponse(tx, row))); + return this.toEntityResponses(tx, rows); } async entityByName( @@ -290,7 +268,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async entityByUid( @@ -307,7 +285,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(tx, rows[0]); + return this.toEntityResponses(tx, rows).then(r => r[0]); } async removeEntityByUid(txOpaque: Transaction, uid: string): Promise { @@ -326,30 +304,12 @@ export class CommonDatabase implements Database { relations: EntityRelationSpec[], ): Promise { const tx = txOpaque as Knex.Transaction; + const relationRows = this.toRelationRows(originatingEntityId, relations); - // remove all relations that exist for the originating entity id. await tx('entities_relations') .where({ originating_entity_id: originatingEntityId }) .del(); - - const serializeName = (e: EntityName) => - `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - - const relationsRows: DbEntitiesRelationsRow[] = relations.map( - ({ source, target, type }) => ({ - originating_entity_id: originatingEntityId, - source_full_name: serializeName(source), - target_full_name: serializeName(target), - type, - }), - ); - - // TODO(blam): translate constraint failures to sane NotFoundError instead - await tx.batchInsert( - 'entities_relations', - deduplicateRelations(relationsRows), - BATCH_SIZE, - ); + await tx.batchInsert('entities_relations', relationRows, BATCH_SIZE); } async addLocation( @@ -370,15 +330,21 @@ export class CommonDatabase implements Database { async removeLocation(txOpaque: Transaction, id: string): Promise { const tx = txOpaque as Knex.Transaction; + const locations = await tx('locations') + .where({ id }) + .select(); + if (!locations.length) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + if (locations[0].type === 'bootstrap') { + throw new ConflictError('You may not delete the bootstrap location.'); + } + await tx('entities') .where({ location_id: id }) .update({ location_id: null }); - - const result = await tx('locations').where({ id }).del(); - - if (!result) { - throw new NotFoundError(`Found no location with ID ${id}`); - } + await tx('locations').where({ id }).del(); } async location(id: string): Promise { @@ -432,7 +398,7 @@ export class CommonDatabase implements Database { async addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise { // Remove log entries older than a day @@ -442,30 +408,19 @@ export class CommonDatabase implements Database { .where('created_at', '<', cutoff.toISOString()) .del(); - await this.database( - 'location_update_log', - ).insert({ - status, - location_id: locationId, - entity_name: entityName, - message, - }); - } + const items: Partial[] = [entityName] + .flat() + .map(n => ({ + status, + location_id: locationId, + entity_name: n, + message, + })); - private async updateEntitiesSearch( - tx: Knex.Transaction, - entityId: string, - data: Entity, - ): Promise { - try { - const entries = buildEntitySearch(entityId, data); - await tx('entities_search') - .where({ entity_id: entityId }) - .del(); - await tx('entities_search').insert(entries); - } catch { - // ignore intentionally - if this happens, the entity was deleted before - // we got around to writing the entries + for (const chunk of lodash.chunk(items, BATCH_SIZE)) { + await this.database( + 'location_update_log', + ).insert(chunk); } } @@ -494,31 +449,77 @@ export class CommonDatabase implements Database { }; } - private async toEntityResponse( - tx: Knex.Transaction, - row: DbEntitiesRow, - ): Promise { - const entity = JSON.parse(row.data) as Entity; - entity.metadata.uid = row.id; - entity.metadata.etag = row.etag; - entity.metadata.generation = Number(row.generation); // cast due to sqlite + private toRelationRows( + originatingEntityId: string, + relations: EntityRelationSpec[], + ): DbEntitiesRelationsRow[] { + const serializeName = (e: EntityName) => + `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); - // TODO(Rugvip): This is here because it's simple for now, but we likely - // need to refactor this to be more efficient or introduce pagination. - const relations = await tx('entities_relations') - .where({ source_full_name: row.full_name }) - .orderBy(['type', 'target_full_name']) - .select(); - - entity.relations = deduplicateRelations(relations).map(r => ({ - target: parseEntityName(r.target_full_name), - type: r.type, + const rows = relations.map(({ source, target, type }) => ({ + originating_entity_id: originatingEntityId, + source_full_name: serializeName(source), + target_full_name: serializeName(target), + type, })); - return { - locationId: row.location_id || undefined, - entity, - }; + return deduplicateRelations(rows); + } + + private async toEntityResponses( + tx: Knex.Transaction, + rows: DbEntitiesRow[], + ): Promise { + // TODO(Rugvip): This is here because it's simple for now, but we likely + // need to refactor this to be more efficient or introduce pagination. + const relations = await this.getRelationsPerFullName( + tx, + rows.map(r => r.full_name), + ); + + const result = new Array(); + for (const row of rows) { + const entity = JSON.parse(row.data) as Entity; + entity.metadata.uid = row.id; + entity.metadata.etag = row.etag; + entity.metadata.generation = Number(row.generation); // cast due to sqlite + + entity.relations = (relations[row.full_name] ?? []).map(r => ({ + target: parseEntityName(r.target_full_name), + type: r.type, + })); + + result.push({ + locationId: row.location_id || undefined, + entity, + }); + } + + return result; + } + + // Returns a mapping from e.g. component:default/foo to the relations whose + // source_full_name matches that. + private async getRelationsPerFullName( + tx: Knex.Transaction, + sourceFullNames: string[], + ): Promise> { + const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500); + + const relations = new Array(); + for (const batch of batches) { + relations.push( + ...(await tx('entities_relations') + .whereIn('source_full_name', batch) + .orderBy(['type', 'target_full_name']) + .select()), + ); + } + + return lodash.groupBy( + deduplicateRelations(relations), + r => r.source_full_name, + ); } } diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 1307b063f2..5a9f60853a 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -33,6 +33,7 @@ export type DbEntitiesRow = { export type DbEntityRequest = { locationId?: string; entity: Entity; + relations: EntityRelationSpec[]; }; export type DbEntityResponse = { @@ -184,10 +185,12 @@ export type Database = { removeEntityByUid(tx: Transaction, uid: string): Promise; /** - * Remove current relations for the entity and replace them with the new relations array + * Remove current relations for the entity and replace them with the new + * relations array. + * * @param tx An ongoing transaction - * @param entityUid the entity uid - * @param relations the relationships to be set + * @param entityUid The entity uid + * @param relations The relationships to be set */ setRelations( tx: Transaction, @@ -208,7 +211,7 @@ export type Database = { addLocationUpdateLogEvent( locationId: string, status: DatabaseLocationUpdateLogStatus, - entityName?: string, + entityName?: string | string[], message?: string, ): Promise; }; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 40b48ca4ab..b9078df93f 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -372,10 +372,9 @@ describe('HigherOrderOperations', () => { '123', undefined, ); - expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith( - '123', + expect(locationsCatalog.logUpdateSuccess).toHaveBeenCalledWith('123', [ 'c1', - ); + ]); }); it('logs unsuccessful updates when reader fails', async () => { diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index bc277e1767..ba97232810 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation { // Write if (!previousLocation && !dryRun) { // TODO: We do not include location operations in the dryRun. We might perform - // this operation as a seperate dry run. + // this operation as a separate dry run. await this.locationsCatalog.addLocation(location); } if (readerOutput.entities.length === 0) { @@ -116,28 +116,34 @@ export class HigherOrderOperations implements HigherOrderOperation { */ async refreshAllLocations(): Promise { const startTimestamp = process.hrtime(); - this.logger.info('Beginning locations refresh'); + const logger = this.logger.child({ + component: 'catalog-all-locations-refresh', + }); + + logger.info('Locations Refresh: Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - this.logger.info(`Visiting ${locations.length} locations`); + logger.info(`Locations Refresh: Visiting ${locations.length} locations`); for (const { data: location } of locations) { - this.logger.info( - `Refreshing location ${location.type}:${location.target}`, + logger.info( + `Locations Refresh: Refreshing location ${location.type}:${location.target}`, ); try { await this.refreshSingleLocation(location); await this.locationsCatalog.logUpdateSuccess(location.id, undefined); } catch (e) { - this.logger.warn( - `Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, + logger.warn( + `Locations Refresh: Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, ); await this.locationsCatalog.logUpdateFailure(location.id, e); } } - this.logger.info( - `Completed locations refresh in ${durationText(startTimestamp)}`, + logger.info( + `Locations Refresh: Completed locations refresh in ${durationText( + startTimestamp, + )}`, ); } @@ -180,14 +186,12 @@ export class HigherOrderOperations implements HigherOrderOperation { throw e; } - this.logger.info(`Posting update success markers`); + this.logger.debug(`Posting update success markers`); - for (const entity of readerOutput.entities) { - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.entity.metadata.name, - ); - } + await this.locationsCatalog.logUpdateSuccess( + location.id, + readerOutput.entities.map(e => e.entity.metadata.name), + ); this.logger.info( `Wrote ${readerOutput.entities.length} entities from location ${ 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..046b6bdf76 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -32,6 +32,7 @@ import { CatalogProcessorEntityResult, CatalogProcessorErrorResult, CatalogProcessorLocationResult, + CatalogProcessorParser, CatalogProcessorResult, } from './processors/types'; import { LocationReader, ReadLocationResult } from './types'; @@ -41,6 +42,7 @@ const MAX_DEPTH = 10; type Options = { reader: UrlReader; + parser: CatalogProcessorParser; logger: Logger; config: Config; processors: CatalogProcessor[]; @@ -78,13 +80,17 @@ export class LocationReaders implements LocationReader { if (rulesEnforcer.isAllowed(item.entity, item.location)) { const relations = Array(); - const entity = await this.handleEntity(item, emitResult => { - if (emitResult.type === 'relation') { - relations.push(emitResult.relation); - return; - } - emit(emitResult); - }); + const entity = await this.handleEntity( + item, + emitResult => { + if (emitResult.type === 'relation') { + relations.push(emitResult.relation); + return; + } + emit(emitResult); + }, + location, + ); if (entity) { output.entities.push({ @@ -133,7 +139,6 @@ export class LocationReaders implements LocationReader { if (emitResult.type === 'relation') { throw new Error('readLocation may not emit entity relations'); } - emit(emitResult); }; @@ -145,6 +150,7 @@ export class LocationReaders implements LocationReader { item.location, item.optional, validatedEmit, + this.options.parser, ) ) { return; @@ -165,6 +171,7 @@ export class LocationReaders implements LocationReader { private async handleEntity( item: CatalogProcessorEntityResult, emit: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { const { processors, logger } = this.options; @@ -185,6 +192,7 @@ export class LocationReaders implements LocationReader { current, item.location, emit, + originLocation, ); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts new file mode 100644 index 0000000000..cfd98e9e6a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; + +describe('AnnotateLocationEntityProcessor', () => { + describe('preProcessEntity', () => { + it('adds annotations', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }; + + const location: LocationSpec = { + type: 'url', + target: 'my-location', + }; + const originLocation: LocationSpec = { + type: 'url', + target: 'my-origin-location', + }; + + const processor = new AnnotateLocationEntityProcessor(); + + expect( + await processor.preProcessEntity( + entity, + location, + () => {}, + originLocation, + ), + ).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'backstage.io/managed-by-location': 'url:my-location', + 'backstage.io/managed-by-origin-location': 'url:my-origin-location', + }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts index ea8afcddc5..b40378226a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts @@ -14,20 +14,28 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + LOCATION_ANNOTATION, + LocationSpec, + ORIGIN_LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; import lodash from 'lodash'; -import { CatalogProcessor } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; export class AnnotateLocationEntityProcessor implements CatalogProcessor { async preProcessEntity( entity: Entity, location: LocationSpec, + _: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { return lodash.merge( { metadata: { annotations: { - 'backstage.io/managed-by-location': `${location.type}:${location.target}`, + [LOCATION_ANNOTATION]: `${location.type}:${location.target}`, + [ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`, }, }, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts new file mode 100644 index 0000000000..6eb7a45c63 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; + +describe('AwsOrganizationCloudAccountProcessor', () => { + describe('readLocation', () => { + const processor = new AwsOrganizationCloudAccountProcessor(); + const location = { type: 'aws-cloud-accounts', target: '' }; + const emit = jest.fn(); + const listAccounts = jest.fn(); + + processor.organizations.listAccounts = listAccounts; + afterEach(() => jest.resetAllMocks()); + + it('generates component entities for accounts', async () => { + listAccounts.mockImplementation(() => + 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: 'Resource', + metadata: { + annotations: { + 'amazonaws.com/arn': + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + 'amazonaws.com/account-id': '957140518395', + 'amazonaws.com/organization-id': 'o-1vl18kc5a3', + }, + name: 'testaccount', + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }, + }); + }); + + it('filters out accounts not in specified location target', async () => { + const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; + listAccounts.mockImplementation(() => + Promise.resolve({ + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + { + Arn: + 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', + Name: 'testaccount2', + }, + ], + NextToken: undefined, + }), + ); + await processor.readLocation(location, false, emit); + expect(emit).toBeCalledTimes(1); + expect(emit).toBeCalledWith({ + type: 'entity', + location, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + 'amazonaws.com/arn': + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + 'amazonaws.com/account-id': '957140518395', + 'amazonaws.com/organization-id': 'o-1vl18kc5a3', + }, + name: 'testaccount', + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts new file mode 100644 index 0000000000..40516f88f7 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; +import { + Account, + Organizations, + ListAccountsCommandOutput, +} from '@aws-sdk/client-organizations'; + +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; + +const AWS_ORGANIZATION_REGION = 'us-east-1'; +const LOCATION_TYPE = 'aws-cloud-accounts'; + +const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id'; +const ARN_ANNOTATION: string = 'amazonaws.com/arn'; +const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; + +/** + * A processor for ingesting AWS Accounts from AWS Organizations. + * + * If custom authentication is needed, it can be achieved by configuring the global AWS.credentials object. + */ +export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { + organizations: Organizations; + constructor() { + this.organizations = new Organizations({ + region: AWS_ORGANIZATION_REGION, + }); // Only available in us-east-1 + } + + normalizeName(name: string): string { + return name + .trim() + .toLocaleLowerCase() + .replace(/[^a-zA-Z0-9\-]/g, '-'); + } + + extractInformationFromArn( + arn: string, + ): { accountId: string; organizationId: string } { + const parts = arn.split('/'); + + return { + accountId: parts[parts.length - 1], + organizationId: parts[parts.length - 2], + }; + } + + async getAwsAccounts(): Promise { + let awsAccounts: Account[] = []; + let isInitialAttempt = true; + let nextToken = undefined; + while (isInitialAttempt || nextToken) { + isInitialAttempt = false; + const orgAccounts: ListAccountsCommandOutput = await this.organizations.listAccounts( + { NextToken: nextToken }, + ); + if (orgAccounts.Accounts) { + awsAccounts = awsAccounts.concat(orgAccounts.Accounts); + } + nextToken = orgAccounts.NextToken; + } + + return awsAccounts; + } + + mapAccountToComponent(account: Account): ResourceEntityV1alpha1 { + const { accountId, organizationId } = this.extractInformationFromArn( + account.Arn as string, + ); + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + [ACCOUNTID_ANNOTATION]: accountId, + [ARN_ANNOTATION]: account.Arn || '', + [ORGANIZATION_ANNOTATION]: organizationId, + }, + name: this.normalizeName(account.Name || ''), + namespace: 'default', + }, + spec: { + type: 'cloud-account', + owner: 'unknown', + }, + }; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== LOCATION_TYPE) { + return false; + } + + (await this.getAwsAccounts()) + .map(account => this.mapAccountToComponent(account)) + .filter(entity => { + if (location.target !== '') { + if (entity.metadata.annotations) { + return ( + entity.metadata.annotations[ORGANIZATION_ANNOTATION] === + location.target + ); + } + return false; + } + return true; + }) + .forEach((entity: ResourceEntityV1alpha1) => { + emit(results.entity(location, entity)); + }); + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index e456dfd7a2..feb4791477 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -17,40 +17,15 @@ import { ApiEntity, ComponentEntity, + DomainEntity, GroupEntity, + ResourceEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; describe('BuiltinKindsEntityProcessor', () => { - it('fills in fields for #3049', async () => { - const p = new BuiltinKindsEntityProcessor(); - const result = await p.preProcessEntity({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'n', - }, - spec: { - type: 't', - children: [], - } as any, - }); - expect(result).toEqual({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'n', - }, - spec: { - type: 't', - children: [], - ancestors: [], - descendants: [], - }, - }); - }); - describe('postProcessEntity', () => { const processor = new BuiltinKindsEntityProcessor(); const location = { type: 'a', target: 'b' }; @@ -66,16 +41,17 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 'service', owner: 'o', + subcomponentOf: 's', lifecycle: 'l', - implementsApis: ['a'], providesApis: ['b'], consumesApis: ['c'], + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(8); + expect(emit).toBeCalledTimes(10); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -92,22 +68,6 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); - expect(emit).toBeCalledWith({ - type: 'relation', - relation: { - source: { kind: 'API', namespace: 'default', name: 'a' }, - type: 'apiProvidedBy', - target: { kind: 'Component', namespace: 'default', name: 'n' }, - }, - }); - expect(emit).toBeCalledWith({ - type: 'relation', - relation: { - source: { kind: 'Component', namespace: 'default', name: 'n' }, - type: 'providesApi', - target: { kind: 'API', namespace: 'default', name: 'a' }, - }, - }); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -140,6 +100,38 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'API', namespace: 'default', name: 'c' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Component', namespace: 'default', name: 's' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); }); it('generates relations for api entities', async () => { @@ -152,12 +144,13 @@ describe('BuiltinKindsEntityProcessor', () => { owner: 'o', lifecycle: 'l', definition: 'd', + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledTimes(4); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -174,6 +167,150 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'API', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for resource entities', async () => { + const entity: ResourceEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { name: 'n' }, + spec: { + type: 'database', + owner: 'o', + system: 's', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for system entities', async () => { + const entity: SystemEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { name: 'n' }, + spec: { + owner: 'o', + domain: 'd', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'd' }, + type: 'hasPart', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Domain', namespace: 'default', name: 'd' }, + }, + }); + }); + + it('generates relations for domain entities', async () => { + const entity: DomainEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { name: 'n' }, + spec: { + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Domain', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); }); it('generates relations for user entities', async () => { @@ -215,9 +352,7 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 't', parent: 'p', - ancestors: [], children: ['c'], - descendants: [], }, }; diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 62b496dd65..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,31 +55,15 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ apiEntityV1alpha1Validator, componentEntityV1alpha1Validator, + resourceEntityV1alpha1Validator, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, templateEntityV1alpha1Validator, userEntityV1alpha1Validator, + systemEntityV1alpha1Validator, + domainEntityV1alpha1Validator, ]; - async preProcessEntity(entity: Entity): Promise { - // NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields - // scheduled for removal Dec 6th, 2020. This code can be deleted after that - // point. See https://github.com/backstage/backstage/issues/3049 - if ( - entity.apiVersion === 'backstage.io/v1alpha1' && - entity.kind === 'Group' && - entity.spec - ) { - if (!entity.spec.ancestors) { - entity.spec.ancestors = []; - } - if (!entity.spec.descendants) { - entity.spec.descendants = []; - } - } - return entity; - } - async validateEntityKind(entity: Entity): Promise { for (const validator of this.validators) { const result = await validator.check(entity); @@ -135,10 +127,10 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNER_OF, ); doEmit( - component.spec.implementsApis, - { defaultKind: 'API', defaultNamespace: selfRef.namespace }, - RELATION_PROVIDES_API, - RELATION_API_PROVIDED_BY, + component.spec.subcomponentOf, + { defaultKind: 'Component', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, ); doEmit( component.spec.providesApis, @@ -152,6 +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, + ); } /* @@ -166,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, + ); } /* @@ -202,6 +226,40 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { ); } + /* + * Emit relations for the System kind + */ + + if (entity.kind === 'System') { + const system = entity as SystemEntity; + doEmit( + system.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + system.spec.domain, + { defaultKind: 'Domain', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); + } + + /* + * Emit relations for the Domain kind + */ + + if (entity.kind === 'Domain') { + const domain = entity as DomainEntity; + doEmit( + domain.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + return entity; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index 068a1e6e9a..be4f15e501 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { CodeOwnersEntry } from 'codeowners-utils'; -import { createLogger } from 'winston'; import { buildCodeOwnerUrl, buildUrl, @@ -28,7 +28,7 @@ import { resolveCodeOwner, } from './CodeOwnersProcessor'; -const logger = createLogger(); +const logger = getVoidLogger(); describe('CodeOwnersProcessor', () => { const mockUrl = ({ basePath = '' } = {}): string => 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/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts index fdba32ef7e..19a08d219f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -16,74 +16,9 @@ import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; -import { - GithubOrgReaderProcessor, - parseUrl, - readConfig, -} from './GithubOrgReaderProcessor'; +import { GithubOrgReaderProcessor, parseUrl } from './GithubOrgReaderProcessor'; describe('GithubOrgReaderProcessor', () => { - describe('readConfig', () => { - function config( - providers: { target: string; apiBaseUrl?: string; token?: string }[], - ) { - return ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { processors: { githubOrg: { providers } } }, - }, - }, - ]); - } - - it('adds a default GitHub entry when missing', () => { - const output = readConfig(config([])); - expect(output).toEqual([ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ]); - }); - - it('injects the correct GitHub API base URL when missing', () => { - const output = readConfig(config([{ target: 'https://github.com' }])); - expect(output).toEqual([ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ]); - }); - - it('rejects custom targets with no base URLs', () => { - expect(() => - readConfig(config([{ target: 'https://ghe.company.com' }])), - ).toThrow( - 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', - ); - }); - - it('rejects funky configs', () => { - expect(() => readConfig(config([{ target: 7 } as any]))).toThrow( - /target/, - ); - expect(() => readConfig(config([{ noTarget: '7' } as any]))).toThrow( - /target/, - ); - expect(() => - readConfig( - config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), - ), - ).toThrow(/apiBaseUrl/); - expect(() => - readConfig(config([{ target: 'https://github.com', token: 7 } as any])), - ).toThrow(/token/); - }); - }); - describe('parseUrl', () => { it('only supports clean org urls, and decodes them', () => { expect(() => parseUrl('https://github.com')).toThrow(); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 9aecb8b86e..6a83d705f6 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -18,9 +18,14 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; +import { + getOrganizationTeams, + getOrganizationUsers, + ProviderConfig, + readGithubConfig, +} from './github'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { getOrganizationTeams, getOrganizationUsers } from './util/github'; import { buildOrgHierarchy } from './util/org'; /** @@ -33,7 +38,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { static fromConfig(config: Config, options: { logger: Logger }) { return new GithubOrgReaderProcessor({ ...options, - providers: readConfig(config), + providers: readGithubConfig(config), }); } @@ -64,6 +69,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { const client = !provider.token ? graphql : graphql.defaults({ + baseUrl: provider.apiBaseUrl, headers: { authorization: `token ${provider.token}`, }, @@ -112,73 +118,6 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { * Helpers */ -/** - * The configuration parameters for a single GitHub API provider. - */ -type ProviderConfig = { - /** - * The prefix of the target that this matches on, e.g. "https://github.com", - * with no trailing slash. - */ - target: string; - - /** - * The base URL of the API of this provider, e.g. "https://api.github.com", - * with no trailing slash. - * - * May be omitted specifically for GitHub; then it will be deduced. - */ - apiBaseUrl?: string; - - /** - * The authorization token to use for requests to this provider. - * - * If no token is specified, anonymous access is used. - */ - token?: string; -}; - -// TODO(freben): Break out common code and config from here and GithubReaderProcessor -export function readConfig(config: Config): ProviderConfig[] { - const providers: ProviderConfig[] = []; - - const providerConfigs = - config.getOptionalConfigArray('catalog.processors.githubOrg.providers') ?? - []; - - // First read all the explicit providers - for (const providerConfig of providerConfigs) { - const target = providerConfig.getString('target').replace(/\/+$/, ''); - let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); - const token = providerConfig.getOptionalString('token'); - - if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); - } else if (target === 'https://github.com') { - apiBaseUrl = 'https://api.github.com'; - } - - if (!apiBaseUrl) { - throw new Error( - `Provider at ${target} must configure an explicit apiBaseUrl`, - ); - } - - providers.push({ target, apiBaseUrl, token }); - } - - // If no explicit github.com provider was added, put one in the list as - // a convenience - if (!providers.some(p => p.target === 'https://github.com')) { - providers.push({ - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }); - } - - return providers; -} - export function parseUrl(urlString: string): { org: string } { const path = new URL(urlString).pathname.substr(1).split('/'); diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts new file mode 100644 index 0000000000..998dfb62a2 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import { toAbsoluteUrl } from './LocationEntityProcessor'; +import path from 'path'; + +describe('LocationEntityProcessor', () => { + describe('toAbsoluteUrl', () => { + it('handles files', () => { + const base: LocationSpec = { + type: 'file', + target: `some${path.sep}path${path.sep}catalog-info.yaml`, + }; + expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe( + `some${path.sep}path${path.sep}c`, + ); + expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`); + }); + + it('handles urls', () => { + const base: LocationSpec = { + type: 'url', + target: 'http://a.com/b/catalog-info.yaml', + }; + expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d'); + expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d'); + expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z'); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts index 86e17f07f2..1c7e13f1c4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -17,27 +17,52 @@ import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import path from 'path'; + +export function toAbsoluteUrl(base: LocationSpec, target: string): string { + try { + if (base.type === 'file') { + if (target.startsWith('.')) { + return path.join(path.dirname(base.target), target); + } + return target; + } + return new URL(target, base.target).toString(); + } catch (e) { + return target; + } +} export class LocationRefProcessor implements CatalogProcessor { async postProcessEntity( entity: Entity, - _location: LocationSpec, + location: LocationSpec, emit: CatalogProcessorEmit, ): Promise { if (entity.kind === 'Location') { - const location = entity as LocationEntity; - if (location.spec.target) { + const locationEntity = entity as LocationEntity; + + const type = locationEntity.spec.type || location.type; + if (type === 'file' && location.target.endsWith(path.sep)) { emit( - result.location( - { type: location.spec.type, target: location.spec.target }, - false, + result.inputError( + location, + `LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, ), ); } - if (location.spec.targets) { - for (const target of location.spec.targets) { - emit(result.location({ type: location.spec.type, target }, false)); - } + + const targets = new Array(); + if (locationEntity.spec.target) { + targets.push(locationEntity.spec.target); + } + if (locationEntity.spec.targets) { + targets.push(...locationEntity.spec.targets); + } + + for (const maybeRelativeTarget of targets) { + const target = toAbsoluteUrl(location, maybeRelativeTarget); + emit(result.location({ type, target }, false)); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index 5a3511af6d..8b2632ad5d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -88,7 +88,7 @@ describe('PlaceholderProcessor', () => { ); }); - it('rejects multiple placeholders', async () => { + it('ignores multiple placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { foo: jest.fn(), @@ -96,41 +96,35 @@ describe('PlaceholderProcessor', () => { }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); - it('rejects unknown placeholders', async () => { + it('ignores unknown placeholders', async () => { const processor = new PlaceholderProcessor({ resolvers: { bar: jest.fn(), }, reader, }); + const entity: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a' } }, + }; await expect( - processor.preProcessEntity( - { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n', x: { $foo: 'a' } }, - }, - { type: 'a', target: 'b' }, - ), - ).rejects.toThrow('Encountered unknown placeholder $foo'); + processor.preProcessEntity(entity, { type: 'a', target: 'b' }), + ).resolves.toEqual(entity); expect(read).not.toBeCalled(); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index 0e23d51f61..b7b3df7b6e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -76,21 +76,28 @@ export class PlaceholderProcessor implements CatalogProcessor { ? [data, false] : [Object.fromEntries(entries.map(([k, [v]]) => [k, v])), true]; } else if (keys.length !== 1) { - throw new Error( - 'Placeholders have to be on the form of a single $-prefixed key in an object', - ); + // This was an object that had more than one key, some of which were + // dollar prefixed. We only handle the case where there is exactly one + // such key; anything else is left alone. + return [data, false]; } const resolverKey = keys[0].substr(1); + const resolverValue = data[keys[0]]; const resolver = this.options.resolvers[resolverKey]; - if (!resolver) { - throw new Error(`Encountered unknown placeholder \$${resolverKey}`); + if (!resolver || typeof resolverValue !== 'string') { + // If there was no such placeholder resolver or if the value was not a + // string, we err on the side of safety and assume that this is + // something that's best left alone. For example, if the input contains + // JSONSchema, there may be "$ref": "#/definitions/node" nodes in the + // document. + return [data, false]; } return [ await resolver({ key: resolverKey, - value: data[keys[0]], + value: resolverValue, baseUrl: location.target, read: this.options.reader.read.bind(this.options.reader), }), diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index 9bf72d620b..49b6fe71e0 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -25,15 +25,21 @@ import { CatalogProcessorErrorResult, CatalogProcessorResult, } from './types'; +import { defaultEntityDataParser } from './util/parse'; 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', @@ -47,7 +53,7 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorEntityResult; expect(generated.type).toBe('entity'); @@ -57,7 +63,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', @@ -71,7 +82,7 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorErrorResult; expect(generated.type).toBe('error'); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index d31f053c80..9daae29d3b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -18,8 +18,11 @@ import { UrlReader } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { Logger } from 'winston'; import * as result from './results'; -import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { parseEntityYaml } from './util/parse'; +import { + CatalogProcessor, + CatalogProcessorEmit, + CatalogProcessorParser, +} from './types'; // TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this // can be removed in a bit @@ -43,13 +46,14 @@ export class UrlReaderProcessor implements CatalogProcessor { location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise { if (deprecatedTypes.includes(location.type)) { - // TODO(Rugvip): Let's not enable this warning yet, as we want to move over the example YAMLs - // in this repo to use the 'url' type first. - // this.options.logger.warn( - // `Using deprecated location type '${location.type}' for '${location.target}', use 'url' instead`, - // ); + // TODO(Rugvip): Remove this warning a month or two into 2021, and remove support for the deprecated types. + this.options.logger.warn( + `Location '${location.target}' uses deprecated location type '${location.type}', use 'url' instead. ` + + 'Use "scripts/migrate-location-types.js" in the Backstage repo to migrate existing locations.', + ); } else if (location.type !== 'url') { return false; } @@ -57,7 +61,7 @@ export class UrlReaderProcessor implements CatalogProcessor { try { const data = await this.options.reader.read(location.target); - for (const parseResult of parseEntityYaml(data, location)) { + for await (const parseResult of parser({ data, location })) { emit(parseResult); } } catch (error) { diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts new file mode 100644 index 0000000000..14f3caa42c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { readGithubConfig } from './config'; + +describe('config', () => { + describe('readGithubConfig', () => { + function config( + providers: { target: string; apiBaseUrl?: string; token?: string }[], + ) { + return new ConfigReader({ + catalog: { processors: { githubOrg: { providers } } }, + }); + } + + it('adds a default GitHub entry when missing', () => { + const output = readGithubConfig(config([])); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('injects the correct GitHub API base URL when missing', () => { + const output = readGithubConfig( + config([{ target: 'https://github.com' }]), + ); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('rejects custom targets with no base URLs', () => { + expect(() => + readGithubConfig(config([{ target: 'https://ghe.company.com' }])), + ).toThrow( + 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', + ); + }); + + it('rejects funky configs', () => { + expect(() => readGithubConfig(config([{ target: 7 } as any]))).toThrow( + /target/, + ); + expect(() => + readGithubConfig(config([{ noTarget: '7' } as any])), + ).toThrow(/target/); + expect(() => + readGithubConfig( + config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readGithubConfig( + config([{ target: 'https://github.com', token: 7 } as any]), + ), + ).toThrow(/token/); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.ts new file mode 100644 index 0000000000..88f2f96218 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; + +/** + * The configuration parameters for a single GitHub API provider. + */ +export type ProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. "https://github.com", + * with no trailing slash. + */ + target: string; + + /** + * The base URL of the API of this provider, e.g. "https://api.github.com", + * with no trailing slash. + * + * May be omitted specifically for GitHub; then it will be deduced. + */ + apiBaseUrl?: string; + + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous access is used. + */ + token?: string; +}; + +// TODO(freben): Break out common code and config from here and GithubReaderProcessor +export function readGithubConfig(config: Config): ProviderConfig[] { + const providers: ProviderConfig[] = []; + + const providerConfigs = + config.getOptionalConfigArray('catalog.processors.githubOrg.providers') ?? + []; + + // First read all the explicit providers + for (const providerConfig of providerConfigs) { + const target = providerConfig.getString('target').replace(/\/+$/, ''); + let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); + const token = providerConfig.getOptionalString('token'); + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (target === 'https://github.com') { + apiBaseUrl = 'https://api.github.com'; + } + + if (!apiBaseUrl) { + throw new Error( + `Provider at ${target} must configure an explicit apiBaseUrl`, + ); + } + + providers.push({ target, apiBaseUrl, token }); + } + + // If no explicit github.com provider was added, put one in the list as + // a convenience + if (!providers.some(p => p.target === 'https://github.com')) { + providers.push({ + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }); + } + + return providers; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts similarity index 90% rename from plugins/catalog-backend/src/ingestion/processors/util/github.test.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 54afdd1bdc..8911e84d99 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ +import { msw } from '@backstage/test-utils'; import { graphql } from '@octokit/graphql'; import { graphql as graphqlMsw } from 'msw'; import { setupServer } from 'msw/node'; -import { msw } from '@backstage/test-utils'; import { getOrganizationTeams, getOrganizationUsers, @@ -78,6 +78,9 @@ describe('github', () => { { slug: 'team', combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', parentTeam: { slug: 'parent', combinedSlug: '', @@ -96,13 +99,18 @@ describe('github', () => { const output = { groups: [ expect.objectContaining({ - metadata: expect.objectContaining({ name: 'team' }), + metadata: expect.objectContaining({ + name: 'team', + description: 'The one and only team', + }), spec: { type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, parent: 'parent', - ancestors: [], children: [], - descendants: [], }, }), ], diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts similarity index 94% rename from plugins/catalog-backend/src/ingestion/processors/util/github.ts rename to plugins/catalog-backend/src/ingestion/processors/github/github.ts index 96cf4a7668..2b33d72f65 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -45,7 +45,9 @@ export type User = { export type Team = { slug: string; combinedSlug: string; + name?: string; description?: string; + avatarUrl?: string; parentTeam?: Team; members: Connection; }; @@ -137,6 +139,9 @@ export async function getOrganizationTeams( nodes { slug combinedSlug + name + description + avatarUrl parentTeam { slug } members(first: 100, membership: IMMEDIATE) { pageInfo { hasNextPage } @@ -162,14 +167,23 @@ export async function getOrganizationTeams( }, spec: { type: 'team', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; - if (team.description) entity.metadata.description = team.description; - if (team.parentTeam) entity.spec.parent = team.parentTeam.slug; + if (team.description) { + entity.metadata.description = team.description; + } + if (team.name) { + entity.spec.profile!.displayName = team.name; + } + if (team.avatarUrl) { + entity.spec.profile!.picture = team.avatarUrl; + } + if (team.parentTeam) { + entity.spec.parent = team.parentTeam.slug; + } const memberNames: string[] = []; groupMemberUsers.set(team.slug, memberNames); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/index.ts b/plugins/catalog-backend/src/ingestion/processors/github/index.ts new file mode 100644 index 0000000000..e424d7bafb --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/github/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 { readGithubConfig } from './config'; +export type { ProviderConfig } from './config'; +export { getOrganizationTeams, getOrganizationUsers } from './github'; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 49d4e03b64..a7b00d7065 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -16,12 +16,8 @@ import * as results from './results'; -export { results }; -export * from './types'; - -export { parseEntityYaml } from './util/parse'; - export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; @@ -32,4 +28,7 @@ export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProce export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; export { StaticLocationProcessor } from './StaticLocationProcessor'; +export * from './types'; export { UrlReaderProcessor } from './UrlReaderProcessor'; +export { parseEntityYaml } from './util/parse'; +export { results }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 7e6036668f..5e293766aa 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -32,9 +32,7 @@ describe('readLdapConfig', () => { }, ], }; - const actual = readLdapConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readLdapConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -66,6 +64,7 @@ describe('readLdapConfig', () => { name: 'cn', description: 'description', type: 'groupType', + displayName: 'cn', memberOf: 'memberOf', members: 'member', }, @@ -114,6 +113,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, @@ -121,9 +123,7 @@ describe('readLdapConfig', () => { }, ], }; - const actual = readLdapConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readLdapConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -161,6 +161,9 @@ describe('readLdapConfig', () => { name: 'v', description: 'd', type: 't', + displayName: 'c', + email: 'm', + picture: 'p', memberOf: 'm', members: 'n', }, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index 2a63b20619..7608f247d4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -109,6 +109,15 @@ export type GroupConfig = { // The name of the attribute that shall be used for the value of the // spec.type field of the entity. Defaults to "groupType". type: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.displayName field of the entity. Defaults to "cn". + displayName: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.email field of the entity. + email?: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.picture field of the entity. + picture?: string; // The name of the attribute that shall be used for the values of the // spec.parent field of the entity. Defaults to "memberOf". memberOf: string; @@ -141,6 +150,7 @@ const defaultConfig = { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', type: 'groupType', memberOf: 'memberOf', members: 'member', @@ -220,6 +230,9 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { name: c.getOptionalString('name'), description: c.getOptionalString('description'), type: c.getOptionalString('type'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), memberOf: c.getOptionalString('memberOf'), members: c.getOptionalString('members'), }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts index e644bab22a..4155c2124e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts @@ -47,7 +47,7 @@ function group(data: RecursivePartial): GroupEntity { apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: 'name' }, - spec: { type: 'type', ancestors: [], children: [], descendants: [] }, + spec: { type: 'type', children: [] }, } as GroupEntity, data, ); @@ -138,6 +138,8 @@ describe('readLdapGroups', () => { cn: ['cn-value'], description: ['description-value'], tt: ['type-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], memberOf: ['x', 'y', 'z'], member: ['e', 'f', 'g'], entryDN: ['dn-value'], @@ -151,6 +153,9 @@ describe('readLdapGroups', () => { rdn: 'cn', name: 'cn', description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', type: 'tt', memberOf: 'memberOf', members: 'member', @@ -173,9 +178,12 @@ describe('readLdapGroups', () => { }, spec: { type: 'type-value', - ancestors: [], + profile: { + displayName: 'cn-value', + email: 'mail-value', + picture: 'avatarUrl-value', + }, children: [], - descendants: [], }, }), ]); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts index 63fce00bd8..f0fb19ac02 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -150,9 +150,8 @@ export async function readLdapGroups( }, spec: { type: 'unknown', - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; @@ -180,6 +179,15 @@ export async function readLdapGroups( mapStringAttr(attributes, map.type, v => { entity.spec.type = v; }); + mapStringAttr(attributes, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(attributes, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(attributes, map.picture, v => { + entity.spec.profile!.picture = v; + }); mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts index e51a7753af..5ef82432bb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts @@ -210,7 +210,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toBeFalsy(); }); - it('should load profile photo', async () => { + it('should load user profile photo', async () => { worker.use( rest.get('https://example.com/users/user-id/photo/*', (_, res, ctx) => res(ctx.status(200), ctx.text('911')), @@ -222,7 +222,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toEqual('data:image/jpeg;base64,OTEx'); }); - it('should load profile photo for size 120', async () => { + it('should load user profile photo for size 120', async () => { worker.use( rest.get( 'https://example.com/users/user-id/photos/120/*', @@ -252,6 +252,46 @@ describe('MicrosoftGraphClient', () => { expect(values).toEqual([{ surname: 'Example' }]); }); + it('should load group profile photo with max size of 120', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photos', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { + height: 120, + id: 120, + }, + ], + }), + ), + ), + ); + worker.use( + rest.get( + 'https://example.com/groups/group-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhotoWithSizeLimit('group-id', 120); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load group profile photo', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photo/*', (_, res, ctx) => + res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhoto('group-id'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + it('should load groups', async () => { worker.use( rest.get('https://example.com/groups', (_, res, ctx) => diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts index 6dded56aa1..3dfc58e773 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts @@ -96,6 +96,10 @@ export class MicrosoftGraphClient { scopes: ['https://graph.microsoft.com/.default'], }); + if (!token) { + throw new Error('Error while requesting token for Microsoft Graph'); + } + return await fetch(url, { headers: { Authorization: `Bearer ${token.accessToken}`, @@ -117,12 +121,65 @@ export class MicrosoftGraphClient { userId: string, maxSize: number, ): Promise { - const response = await this.requestApi(`users/${userId}/photos`); + return await this.getPhotoWithSizeLimit('users', userId, maxSize); + } + + async getUserPhoto( + userId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('users', userId, sizeId); + } + + async *getUsers(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`users`, query); + } + + async getGroupPhotoWithSizeLimit( + groupId: string, + maxSize: number, + ): Promise { + return await this.getPhotoWithSizeLimit('groups', groupId, maxSize); + } + + async getGroupPhoto( + groupId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('groups', groupId, sizeId); + } + + async *getGroups(query?: ODataQuery): AsyncIterable { + yield* this.requestCollection(`groups`, query); + } + + async *getGroupMembers(groupId: string): AsyncIterable { + yield* this.requestCollection(`groups/${groupId}/members`); + } + + async getOrganization( + tenantId: string, + ): Promise { + const response = await this.requestApi(`organization/${tenantId}`); + + if (response.status !== 200) { + await this.handleError(`organization/${tenantId}`, response); + } + + return await response.json(); + } + + private async getPhotoWithSizeLimit( + entityName: string, + id: string, + maxSize: number, + ): Promise { + const response = await this.requestApi(`${entityName}/${id}/photos`); if (response.status === 404) { return undefined; } else if (response.status !== 200) { - await this.handleError('user photos', response); + await this.handleError(`${entityName} photos`, response); } const result = await response.json(); @@ -143,16 +200,17 @@ export class MicrosoftGraphClient { return undefined; } - return await this.getUserPhoto(userId, selectedPhoto.id!); + return await this.getPhoto(entityName, id, selectedPhoto.id!); } - async getUserPhoto( - userId: string, + private async getPhoto( + entityName: string, + id: string, sizeId?: string, ): Promise { const path = sizeId - ? `users/${userId}/photos/${sizeId}/$value` - : `users/${userId}/photo/$value`; + ? `${entityName}/${id}/photos/${sizeId}/$value` + : `${entityName}/${id}/photo/$value`; const response = await this.requestApi(path); if (response.status === 404) { @@ -166,30 +224,6 @@ export class MicrosoftGraphClient { ).toString('base64')}`; } - async *getUsers(query?: ODataQuery): AsyncIterable { - yield* this.requestCollection(`users`, query); - } - - async *getGroups(query?: ODataQuery): AsyncIterable { - yield* this.requestCollection(`groups`, query); - } - - async *getGroupMembers(groupId: string): AsyncIterable { - yield* this.requestCollection(`groups/${groupId}/members`); - } - - async getOrganization( - tenantId: string, - ): Promise { - const response = await this.requestApi(`organization/${tenantId}`); - - if (response.status !== 200) { - await this.handleError('organization/${tenantId}', response); - } - - return await response.json(); - } - private async handleError(path: string, response: Response): Promise { const result = await response.json(); const error = result.error as MicrosoftGraph.PublicError; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts index 11c63828d4..4671fd23ae 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.test.ts @@ -29,9 +29,7 @@ describe('readMicrosoftGraphConfig', () => { }, ], }; - const actual = readMicrosoftGraphConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readMicrosoftGraphConfig(new ConfigReader(config)); const expected = [ { target: 'target', @@ -60,9 +58,7 @@ describe('readMicrosoftGraphConfig', () => { }, ], }; - const actual = readMicrosoftGraphConfig( - ConfigReader.fromConfigs([{ context: '', data: config }]), - ); + const actual = readMicrosoftGraphConfig(new ConfigReader(config)); const expected = [ { target: 'target', diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts index c4d09e1372..72416a63ee 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/config.ts @@ -16,13 +16,46 @@ import { Config } from '@backstage/config'; +/** + * The configuration parameters for a single Microsoft Graph provider. + */ export type MicrosoftGraphProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. + * "https://graph.microsoft.com/v1.0", with no trailing slash. + */ target: string; - authority: string; + /** + * The auth authority used. + * + * E.g. "https://login.microsoftonline.com" + */ + authority?: string; + /** + * The tenant whose org data we are interested in. + */ tenantId: string; + /** + * The OAuth client ID to use for authenticating requests. + */ clientId: string; + /** + * The OAuth client secret to use for authenticating requests. + * + * @visibility secret + */ clientSecret: string; + /** + * The filter to apply to extract users. + * + * E.g. "accountEnabled eq true and userType eq 'member'" + */ userFilter?: string; + /** + * The filter to apply to extract groups. + * + * E.g. "securityEnabled eq false and mailEnabled eq true" + */ groupFilter?: string; }; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts index 3c2f33e922..07d540c848 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts @@ -49,9 +49,7 @@ function group(data: RecursivePartial): GroupEntity { name: 'name', }, spec: { - ancestors: [], children: [], - descendants: [], type: 'team', }, } as GroupEntity, @@ -65,6 +63,7 @@ describe('read microsoft graph', () => { getGroups: jest.fn(), getGroupMembers: jest.fn(), getUserPhotoWithSizeLimit: jest.fn(), + getGroupPhotoWithSizeLimit: jest.fn(), getOrganization: jest.fn(), } as any; @@ -152,6 +151,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }), ); @@ -167,6 +169,8 @@ describe('read microsoft graph', () => { yield { id: 'groupid', displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', }; } @@ -187,6 +191,9 @@ describe('read microsoft graph', () => { id: 'tenantid', displayName: 'Organization Name', }); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); const { groups, @@ -207,6 +214,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }); expect(groups).toEqual([ @@ -217,10 +227,17 @@ describe('read microsoft graph', () => { 'graph.microsoft.com/group-id': 'groupid', }, name: 'group_name', - description: 'Group Name', + description: 'Group Description', }, spec: { type: 'team', + profile: { + displayName: 'Group Name', + email: 'group@example.com', + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, }, }), ]); @@ -232,10 +249,14 @@ describe('read microsoft graph', () => { expect(client.getGroups).toBeCalledTimes(1); expect(client.getGroups).toBeCalledWith({ filter: 'securityEnabled eq false', - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], }); expect(client.getGroupMembers).toBeCalledTimes(1); expect(client.getGroupMembers).toBeCalledWith('groupid'); + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1); + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120); }); }); @@ -306,33 +327,20 @@ describe('read microsoft graph', () => { resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf); expect(rootGroup.spec.parent).toBeUndefined(); - expect(rootGroup.spec.ancestors).toEqual(expect.arrayContaining([])); expect(rootGroup.spec.children).toEqual( expect.arrayContaining(['a', 'b']), ); - expect(rootGroup.spec.descendants).toEqual( - expect.arrayContaining(['a', 'b', 'c']), - ); expect(groupA.spec.parent).toEqual('root'); - expect(groupA.spec.ancestors).toEqual(expect.arrayContaining(['root'])); expect(groupA.spec.children).toEqual(expect.arrayContaining([])); - expect(groupA.spec.descendants).toEqual(expect.arrayContaining([])); expect(groupB.spec.parent).toEqual('root'); - expect(groupB.spec.ancestors).toEqual(expect.arrayContaining(['root'])); expect(groupB.spec.children).toEqual(expect.arrayContaining(['c'])); - expect(groupB.spec.descendants).toEqual(expect.arrayContaining(['c'])); expect(groupC.spec.parent).toEqual('b'); - expect(groupC.spec.ancestors).toEqual( - expect.arrayContaining(['root', 'b']), - ); expect(groupC.spec.children).toEqual(expect.arrayContaining([])); - expect(groupC.spec.descendants).toEqual(expect.arrayContaining([])); expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a'])); - expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c'])); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts index 6cde2649c4..4409422645 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import limiterFactory from 'p-limit'; import { buildMemberOf, buildOrgHierarchy } from '../util/org'; import { MicrosoftGraphClient } from './client'; import { @@ -21,7 +22,6 @@ import { MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, MICROSOFT_GRAPH_USER_ID_ANNOTATION, } from './constants'; -import limiterFactory from 'p-limit'; export function normalizeEntityName(name: string): string { return name @@ -37,7 +37,7 @@ export async function readMicrosoftGraphUsers( users: UserEntity[]; // With all relations empty }> { const entities: UserEntity[] = []; - const picturePromises: Promise[] = []; + const promises: Promise[] = []; const limiter = limiterFactory(10); for await (const user of client.getUsers({ @@ -82,12 +82,12 @@ export async function readMicrosoftGraphUsers( ); }); - picturePromises.push(loadPhoto); + promises.push(loadPhoto); entities.push(entity); } // Wait for all photos to be downloaded - await Promise.all(picturePromises); + await Promise.all(promises); return { users: entities }; } @@ -98,7 +98,7 @@ export async function readMicrosoftGraphOrganization( ): Promise<{ rootGroup: GroupEntity; // With all relations empty }> { - // For now we expect a single root orgranization + // For now we expect a single root organization const organization = await client.getOrganization(tenantId); const name = normalizeEntityName(organization.displayName!); const rootGroup: GroupEntity = { @@ -113,9 +113,10 @@ export async function readMicrosoftGraphOrganization( }, spec: { type: 'root', - ancestors: [], + profile: { + displayName: organization.displayName!, + }, children: [], - descendants: [], }, }; @@ -141,11 +142,11 @@ export async function readMicrosoftGraphGroups( groupMember.set(rootGroup.metadata.name, new Set()); groups.push(rootGroup); - const groupMemberPromises: Promise[] = []; + const promises: Promise[] = []; for await (const group of client.getGroups({ filter: options?.groupFilter, - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], })) { if (!group.id || !group.displayName) { continue; @@ -157,20 +158,27 @@ export async function readMicrosoftGraphGroups( kind: 'Group', metadata: { name: name, - description: group.displayName, annotations: { [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, }, }, spec: { type: 'team', - // TODO: We could include a group email and picture - ancestors: [], + profile: {}, children: [], - descendants: [], }, }; + if (group.description) { + entity.metadata.description = group.description; + } + if (group.displayName) { + entity.spec.profile!.displayName = group.displayName; + } + if (group.mail) { + entity.spec.profile!.email = group.mail; + } + // Download the members in parallel, otherwise it can take quite some time const loadGroupMembers = limiter(async () => { for await (const member of client.getGroupMembers(group.id!)) { @@ -188,12 +196,25 @@ export async function readMicrosoftGraphGroups( } }); - groupMemberPromises.push(loadGroupMembers); + // TODO: Loading groups doesn't work right now as Microsoft Graph doesn't + // allows this yet: https://microsoftgraph.uservoice.com/forums/920506-microsoft-graph-feature-requests/suggestions/37884922-allow-application-to-set-or-update-a-group-s-photo + /*/ / Download the photos in parallel, otherwise it can take quite some time + const loadPhoto = limiter(async () => { + entity.spec.profile!.picture = await client.getGroupPhotoWithSizeLimit( + group.id!, + // We are limiting the photo size, as groups with full resolution photos + // can make the Backstage API slow + 120, + ); + }); + + promises.push(loadPhoto);*/ + promises.push(loadGroupMembers); groups.push(entity); } - // Wait for all group members to be loaded - await Promise.all(groupMemberPromises); + // Wait for all group members and photos to be loaded + await Promise.all(promises); return { groups, @@ -278,7 +299,7 @@ export function resolveRelations( }); }); - // Make sure that all groups have proper ancestors and descendants + // Make sure that all groups have proper parents and children buildOrgHierarchy(groups); // Set relations for all users diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 5da3510f7a..f7e11d5616 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -27,12 +27,15 @@ export type CatalogProcessor = { * @param location The location to read * @param optional Whether a missing target should trigger an error * @param emit A sink for items resulting from the read + * @param parser A parser, that is able to take the raw catalog descriptor + * data and turn it into the actual result pieces. * @returns True if handled by this processor, false otherwise */ readLocation?( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise; /** @@ -46,12 +49,16 @@ export type CatalogProcessor = { * @param entity The (possibly partial) entity to process * @param location The location that the entity came from * @param emit A sink for auxiliary items resulting from the processing + * @param originLocation The location that the entity originally came from. + * While location resolves to the direct parent location, originLocation + * tells which location was used to start the ingestion loop. * @returns The same entity or a modified version of it */ preProcessEntity?( entity: Entity, location: LocationSpec, emit: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise; /** @@ -96,6 +103,16 @@ export type CatalogProcessor = { ): Promise; }; +/** + * A parser, that is able to take the raw catalog descriptor data and turn it + * into the actual result pieces. The default implementation performs a YAML + * document parsing. + */ +export type CatalogProcessorParser = (options: { + data: Buffer; + location: LocationSpec; +}) => AsyncIterable; + export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void; export type CatalogProcessorLocationResult = { diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts index f7afd63101..c9056eebb4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts @@ -26,7 +26,7 @@ function g( apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name }, - spec: { type: 'team', parent, children, ancestors: [], descendants: [] }, + spec: { type: 'team', parent, children }, }; } @@ -43,28 +43,16 @@ describe('buildOrgHierarchy', () => { expect(d.spec.children).toEqual([]); }); - it('fills out descendants', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); + it('sets parent of groups children', () => { + const a = g('a', undefined, ['b', 'd']); + const b = g('b', undefined, ['c']); + const c = g('c', undefined, []); + const d = g('d', undefined, []); buildOrgHierarchy([a, b, c, d]); - expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd'])); - expect(b.spec.descendants).toEqual(expect.arrayContaining(['c'])); - expect(c.spec.descendants).toEqual([]); - expect(d.spec.descendants).toEqual([]); - }); - - it('fills out ancestors', () => { - const a = g('a', undefined, []); - const b = g('b', 'a', []); - const c = g('c', 'b', []); - const d = g('d', 'a', []); - buildOrgHierarchy([a, b, c, d]); - expect(a.spec.ancestors).toEqual([]); - expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a'])); - expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b'])); - expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a'])); + expect(a.spec.parent).toBeUndefined(); + expect(b.spec.parent).toBe('a'); + expect(c.spec.parent).toBe('b'); + expect(d.spec.parent).toBe('a'); }); }); @@ -76,7 +64,7 @@ describe('buildMemberOf', () => { const u: UserEntity = { apiVersion: 'backstage.io/v1alpha1', kind: 'User', - metadata: { name }, + metadata: { name: 'n' }, spec: { profile: {}, memberOf: ['c'] }, }; diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.ts index b033fe99d7..787e408e60 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.ts @@ -35,62 +35,17 @@ export function buildOrgHierarchy(groups: GroupEntity[]) { } // - // Make sure that g.descendants is complete + // Make sure that g.children.parent is g // - function visitDescendants(current: GroupEntity): string[] { - if (current.spec.descendants.length) { - return current.spec.descendants; - } - - const accumulator = new Set(); - for (const childName of current.spec.children) { - accumulator.add(childName); + for (const group of groups) { + const selfName = group.metadata.name; + for (const childName of group.spec.children) { const child = groupsByName.get(childName); - if (child) { - for (const d of visitDescendants(child)) { - accumulator.add(d); - } + if (child && !child.spec.parent) { + child.spec.parent = selfName; } } - - const descendants = Array.from(accumulator); - current.spec.descendants = descendants; - return descendants; - } - - for (const group of groups) { - visitDescendants(group); - } - - // - // Make sure that g.ancestors is complete - // - - function visitAncestors(current: GroupEntity): string[] { - if (current.spec.ancestors.length) { - return current.spec.ancestors; - } - - let ancestors: string[]; - const parentName = current.spec.parent; - if (!parentName) { - ancestors = []; - } else { - const parent = groupsByName.get(parentName); - if (parent) { - ancestors = [parentName, ...visitAncestors(parent)]; - } else { - ancestors = [parentName]; - } - } - - current.spec.ancestors = ancestors; - return ancestors; - } - - for (const group of groups) { - visitAncestors(group); } } @@ -100,15 +55,24 @@ export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) { const groupsByName = new Map(groups.map(g => [g.metadata.name, g])); users.forEach(user => { - const transitiveMemberOf = new Set([...user.spec.memberOf]); + const transitiveMemberOf = new Set(); - user.spec.memberOf.forEach(groupName => { - const group = groupsByName.get(groupName); - - if (group) { - group.spec.ancestors.forEach(g => transitiveMemberOf.add(g)); + const todo = [...user.spec.memberOf]; + for (;;) { + const current = todo.pop(); + if (!current) { + break; } - }); + + if (!transitiveMemberOf.has(current)) { + transitiveMemberOf.add(current); + const group = groupsByName.get(current); + if (group?.spec.parent) { + todo.push(group.spec.parent); + } + } + } + user.spec.memberOf = [...transitiveMemberOf]; }); } diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts index c3dcd42d62..aa24968d6d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts @@ -18,7 +18,7 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import lodash from 'lodash'; import yaml from 'yaml'; import * as result from '../results'; -import { CatalogProcessorResult } from '../types'; +import { CatalogProcessorParser, CatalogProcessorResult } from '../types'; export function* parseEntityYaml( data: Buffer, @@ -50,3 +50,12 @@ export function* parseEntityYaml( } } } + +export const defaultEntityDataParser: CatalogProcessorParser = async function* defaultEntityDataParser({ + data, + location, +}) { + for (const e of parseEntityYaml(data, location)) { + yield e; + } +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index 58897a10a7..695902fec9 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config'; import Knex from 'knex'; import yaml from 'yaml'; import { DatabaseManager } from '../database'; +import { CatalogProcessorParser } from '../ingestion'; import * as result from '../ingestion/processors/results'; import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder'; @@ -47,7 +48,7 @@ describe('CatalogBuilder', () => { const env: CatalogEnvironment = { logger: getVoidLogger(), database: { getClient: async () => db }, - config: ConfigReader.fromConfigs([]), + config: new ConfigReader({}), reader, }; @@ -209,4 +210,26 @@ describe('CatalogBuilder', () => { }), ]); }); + + it('setEntityDataParser works', async () => { + const mockParser: CatalogProcessorParser = jest + .fn() + .mockImplementation(() => {}); + + const builder = new CatalogBuilder(env) + .setEntityDataParser(mockParser) + .replaceProcessors([ + { + async readLocation(_location, _optional, _emit, parser) { + expect(parser).toBe(mockParser); + return true; + }, + }, + ]); + + const { higherOrderOperation } = await builder.build(); + await higherOrderOperation.addLocation({ type: 'x', target: 'y' }); + + expect.assertions(1); + }); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index f6170135fd..938a406e56 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -39,6 +39,7 @@ import { AnnotateLocationEntityProcessor, BuiltinKindsEntityProcessor, CatalogProcessor, + CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, @@ -60,6 +61,7 @@ import { textPlaceholderResolver, yamlPlaceholderResolver, } from '../ingestion/processors/PlaceholderProcessor'; +import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; export type CatalogEnvironment = { @@ -96,6 +98,7 @@ export class CatalogBuilder { private fieldFormatValidators: Partial; private processors: CatalogProcessor[]; private processorsReplace: boolean; + private parser: CatalogProcessorParser | undefined; constructor(env: CatalogEnvironment) { this.env = env; @@ -105,6 +108,7 @@ export class CatalogBuilder { this.fieldFormatValidators = {}; this.processors = []; this.processorsReplace = false; + this.parser = undefined; } /** @@ -197,6 +201,20 @@ export class CatalogBuilder { return this; } + /** + * Sets up the catalog to use a custom parser for entity data. + * + * This is the function that gets called immediately after some raw entity + * specification data has been read from a remote source, and needs to be + * parsed and emitted as structured data. + * + * @param parser The custom parser + */ + setEntityDataParser(parser: CatalogProcessorParser): CatalogBuilder { + this.parser = parser; + return this; + } + /** * Wires up and returns all of the component parts of the catalog */ @@ -211,9 +229,11 @@ export class CatalogBuilder { const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config); + const parser = this.parser || defaultEntityDataParser; const locationReader = new LocationReaders({ ...this.env, + parser, processors, rulesEnforcer, policy, diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index 56911b9c45..ad536e45dc 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/plugin-catalog-graphql +## 0.2.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [c911061b7] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [4eafdec4a] +- Updated dependencies [ac3560b42] + - @backstage/catalog-model@0.6.0 + - @backstage/backend-common@0.4.1 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [38e24db00] +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [12bbd748c] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [e3bd9fc2f] + - @backstage/backend-common@0.4.0 + - @backstage/config@0.1.2 + - @backstage/catalog-model@0.5.0 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [612368274] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [bcc211a08] + - @backstage/backend-common@0.3.3 + - @backstage/catalog-model@0.4.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index a489d66e5b..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.2", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,16 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-graphql" + }, + "keywords": [ + "backstage", + "graphql" + ], "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.1", - "@backstage/catalog-model": "^0.3.0", - "@backstage/config": "^0.1.1", + "@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", "cross-fetch": "^3.0.6", @@ -32,8 +42,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.3.1", - "@backstage/test-utils": "^0.1.3", + "@backstage/cli": "^0.4.7", + "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-graphql/src/graphql/module.test.ts b/plugins/catalog-graphql/src/graphql/module.test.ts index b359c4396b..e6e9d59df7 100644 --- a/plugins/catalog-graphql/src/graphql/module.test.ts +++ b/plugins/catalog-graphql/src/graphql/module.test.ts @@ -27,16 +27,11 @@ import { gql } from 'apollo-server'; describe('Catalog Module', () => { const worker = setupServer(); const mockCatalogBaseUrl = 'http://im.mock'; - const mockConfig = ConfigReader.fromConfigs([ - { - context: '', - data: { - backend: { - baseUrl: mockCatalogBaseUrl, - }, - }, + const mockConfig = new ConfigReader({ + backend: { + baseUrl: mockCatalogBaseUrl, }, - ]); + }); msw.setupDefaultHandlers(worker); diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md new file mode 100644 index 0000000000..2d75ae6af4 --- /dev/null +++ b/plugins/catalog-import/CHANGELOG.md @@ -0,0 +1,143 @@ +# @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 + +- f3e75508d: Add description to Pull Request when registering a new component +- Updated dependencies [c6eeefa35] +- Updated dependencies [fb386b760] +- Updated dependencies [c911061b7] +- Updated dependencies [7c3ffc0cd] +- Updated dependencies [e7496dc3e] +- Updated dependencies [8ef71ed32] +- Updated dependencies [1d1c2860f] +- Updated dependencies [0e6298f7e] +- Updated dependencies [8dd0a906d] +- Updated dependencies [4eafdec4a] +- Updated dependencies [6b37c95bf] +- Updated dependencies [8c31c681c] +- Updated dependencies [7b98e7fee] +- Updated dependencies [178e09323] +- Updated dependencies [ac3560b42] +- Updated dependencies [0097057ed] + - @backstage/plugin-catalog-backend@0.5.0 + - @backstage/catalog-model@0.6.0 + - @backstage/core@0.4.1 + - @backstage/integration@0.1.4 + - @backstage/plugin-catalog@0.2.7 + +## 0.3.1 + +### Patch Changes + +- 79418ddb6: Align plugin ID and fix variable typo +- d2938af4c: Add register existing component instructions +- Updated dependencies [6e8bb3ac0] +- Updated dependencies [2527628e1] +- Updated dependencies [e708679d7] +- Updated dependencies [047c018c9] +- Updated dependencies [6011b7d3e] +- Updated dependencies [1c69d4716] +- Updated dependencies [38d63fbe1] +- Updated dependencies [83b6e0c1f] +- Updated dependencies [1665ae8bb] +- Updated dependencies [04f26f88d] +- Updated dependencies [ff243ce96] + - @backstage/plugin-catalog-backend@0.4.0 + - @backstage/core@0.4.0 + - @backstage/plugin-catalog@0.2.6 + - @backstage/catalog-model@0.5.0 + - @backstage/theme@0.2.2 + +## 0.3.0 + +### Minor Changes + +- a9fd599f7: Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file: + + ```ts + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + } = await builder.build(); + + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + locationAnalyzer, //<-- + logger: env.logger, + }); + } + ``` + +### Patch Changes + +- Updated dependencies [b4488ddb0] +- Updated dependencies [08835a61d] +- Updated dependencies [a9fd599f7] +- Updated dependencies [e42402b47] +- Updated dependencies [bcc211a08] +- Updated dependencies [ebf37bbae] + - @backstage/plugin-catalog-backend@0.3.0 + - @backstage/catalog-model@0.4.0 + - @backstage/plugin-catalog@0.2.5 diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 11562b741e..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.2.0", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,15 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-import" + }, + "keywords": [ + "backstage" + ], "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,16 +30,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.3.0", - "@backstage/core": "^0.3.2", - "@backstage/plugin-catalog": "^0.2.0", - "@backstage/plugin-catalog-backend": "^0.2.2", - "@backstage/theme": "^0.2.1", + "@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", - "git-url-parse": "^11.4.0", + "@octokit/rest": "^18.0.12", + "git-url-parse": "^11.4.3", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.6.0", @@ -40,9 +49,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.3.2", - "@backstage/dev-utils": "^0.1.4", - "@backstage/test-utils": "^0.1.3", + "@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", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 49012764e2..5abb0e3e53 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -16,9 +16,10 @@ import { createApiRef } from '@backstage/core'; import { PartialEntity } from '../util/types'; +import { GitHubIntegrationConfig } from '@backstage/integration'; export const catalogImportApiRef = createApiRef({ - id: 'plugin.catalogimport.service', + id: 'plugin.catalog-import.service', description: 'Used by the catalog import plugin to make requests', }); @@ -27,7 +28,13 @@ export interface CatalogImportApi { owner: string; repo: string; 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 94ba260d55..d1509c2105 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -15,21 +15,24 @@ */ import { Octokit } from '@octokit/rest'; -import { DiscoveryApi, OAuthApi } from '@backstage/core'; +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'; export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; private readonly githubAuthApi: OAuthApi; + private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; githubAuthApi: OAuthApi; + configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; this.githubAuthApi = options.githubAuthApi; + this.configApi = options.configApi; } async generateEntityDefinitions({ @@ -45,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 => { @@ -57,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({ @@ -74,7 +77,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - type: 'github', + type: 'url', target: location, presence: 'optional', }), @@ -87,19 +90,68 @@ 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, fileContent, + githubIntegrationConfig, }: { owner: string; repo: string; fileContent: string; + githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }> { const token = await this.githubAuthApi.getAccessToken(['repo']); const octo = new Octokit({ auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, }); const branchName = 'backstage-integration'; @@ -160,12 +212,23 @@ export class CatalogImportClient implements CatalogImportApi { ); }); - const pullRequestRespone = await octo.pulls + const appTitle = + this.configApi.getOptionalString('app.title') ?? 'Backstage'; + const appBaseUrl = this.configApi.getString('app.baseUrl'); + + const prBody = `This pull request adds a **Backstage entity metadata file** \ +to this repository so that the component can be added to the \ +[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \ +the component will become available.\n\nFor more information, read an \ +[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`; + + const pullRequestResponse = await octo.pulls .create({ owner, repo, title: `Add ${fileName} config file`, head: branchName, + body: prBody, base: repoData.data.default_branch, }) .catch(e => { @@ -178,8 +241,8 @@ export class CatalogImportClient implements CatalogImportApi { }); return { - link: pullRequestRespone.data.html_url, - location: `https://github.com/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, + link: pullRequestResponse.data.html_url, + location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`, }; } } diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index f3dda35003..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, @@ -74,14 +74,14 @@ const ComponentConfigDisplay = ({ savePRLink, catalogRouteRef, }: Props) => { - const [errorOccured, setErrorOccured] = useState(false); + const [errorOccurred, setErrorOccurred] = useState(false); const [submitting, setSubmitting] = useState(false); const errorApi = useApi(errorApiRef); const { submitPrToRepo, addLocation } = useGithubRepos(); const onNext = useCallback(async () => { try { setSubmitting(true); - if (!parseGitUri(configFile.location).filepathtype) { + if (urlType(configFile.location) === 'tree') { const result = await submitPrToRepo(configFile); savePRLink(result.link); setSubmitting(false); @@ -92,7 +92,7 @@ const ComponentConfigDisplay = ({ nextStep(); } } catch (e) { - setErrorOccured(true); + setErrorOccurred(true); setSubmitting(false); errorApi.post(e); } @@ -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)}
) : ( @@ -178,7 +178,7 @@ const ComponentConfigDisplay = ({ > Next - {errorOccured ? ( + {errorOccurred ? (
} + /> + +
+ ), + }, + ]} + /> + } + /> + , + { routeEntries: [route] }, + ); + + it('works for nested content', async () => { + const rendered = await renderRoute('/some-other-path/nested'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).toBeInTheDocument(); + }); + + it('works for non-nested content', async () => { + const rendered = await renderRoute('/some-other-path/'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).not.toBeInTheDocument(); + }); + }); + + it('shows only one tab contents at a time', async () => { + const rendered = await renderInTestApp( + , + { routeEntries: ['/some-other-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + it('redirects to the top level when no route is matching the url', async () => { + const rendered = await renderInTestApp( + , + { routeEntries: ['/non-existing-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + + expect( + rendered.queryByText('tabbed-test-content-2'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx new file mode 100644 index 0000000000..a2209ba492 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useMemo } from 'react'; +import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router'; +import { HeaderTabs, Content as LayoutContent } from '@backstage/core'; +import { Helmet } from 'react-helmet'; +import { SubRoute } from './types'; + +export function useSelectedSubRoute( + subRoutes: SubRoute[], +): { index: number; route: SubRoute; element: JSX.Element } { + const params = useParams(); + + const routes = subRoutes.map(({ path, children }) => ({ + caseSensitive: false, + path: `${path}/*`, + element: children, + })); + + const element = useRoutes(routes) ?? subRoutes[0].children; + + const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? []; + const foundIndex = matchedRoute + ? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path) + : 0; + + return { + index: foundIndex === -1 ? 0 : foundIndex, + element, + route: subRoutes[foundIndex] ?? subRoutes[0], + }; +} + +export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { + const navigate = useNavigate(); + const { index, route, element } = useSelectedSubRoute(routes); + const headerTabs = useMemo( + () => routes.map(t => ({ id: t.path, label: t.title })), + [routes], + ); + + const onTabChange = (index: number) => + // Remove trailing /* + // And remove leading / for relative navigation + // Note! route resolves relative to the position in the React tree, + // not relative to current location + navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, '')); + + return ( + <> + + + + {element} + + + ); +}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/plugins/catalog/src/components/EntityLayout/index.ts similarity index 92% rename from plugins/kubernetes/src/components/ConfigMaps/index.ts rename to plugins/catalog/src/components/EntityLayout/index.ts index b1c7d37a5a..2e399765c3 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/index.ts +++ b/plugins/catalog/src/components/EntityLayout/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ConfigMaps } from './ConfigMaps'; +export { EntityLayout } from './EntityLayout'; diff --git a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts b/plugins/catalog/src/components/EntityLayout/types.ts similarity index 87% rename from plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts rename to plugins/catalog/src/components/EntityLayout/types.ts index 1b7d35c0a2..29ade88dc0 100644 --- a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts +++ b/plugins/catalog/src/components/EntityLayout/types.ts @@ -14,4 +14,8 @@ * limitations under the License. */ -export { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; +export type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; diff --git a/plugins/api-docs/src/components/useComponentApiNames.ts b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx similarity index 62% rename from plugins/api-docs/src/components/useComponentApiNames.ts rename to plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx index 1303967895..98b8295047 100644 --- a/plugins/api-docs/src/components/useComponentApiNames.ts +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -13,17 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import React, { ReactNode } from 'react'; +import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; -import { - ComponentEntity, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; +export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); -export const useComponentApiNames = (entity: ComponentEntity) => { - // TODO: This code doesn't handle namespaces and kinds correctly, but will be removed soon return ( - entity.relations - ?.filter(r => r.type === RELATION_PROVIDES_API) - ?.map(r => r.target.name) || [] + + {children} + ); }; diff --git a/plugins/catalog/src/components/EntityLoaderProvider/index.ts b/plugins/catalog/src/components/EntityLoaderProvider/index.ts new file mode 100644 index 0000000000..925c927ec9 --- /dev/null +++ b/plugins/catalog/src/components/EntityLoaderProvider/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityLoaderProvider } from './EntityLoaderProvider'; diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 8c48d69413..eb75593ffc 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -39,12 +39,12 @@ const EntityPageTitle = ({ ); -function headerProps( +const headerProps = ( kind: string, namespace: string | undefined, name: string, entity: Entity | undefined, -): { headerTitle: string; headerType: string } { +): { headerTitle: string; headerType: string } => { return { headerTitle: `${name}${ namespace && namespace !== ENTITY_DEFAULT_NAMESPACE @@ -60,7 +60,7 @@ function headerProps( return t; })(), }; -} +}; export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { const { kind, namespace, name } = useEntityCompoundName(); @@ -88,7 +88,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { pageTitleOverride={headerTitle} type={headerType} > - {entity && ( + {/* TODO: fix after catalog page customization is added */} + {entity && kind !== 'user' && ( <> { - const { entity, loading, error } = useEntityFromUrl(); - - return ( - - {children} - - ); +type EntityProviderProps = { + entity: Entity; + children: ReactNode; }; + +export const EntityProvider = ({ entity, children }: EntityProviderProps) => ( + + {children} + +); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx new file mode 100644 index 0000000000..b6fe8a77d8 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx @@ -0,0 +1,162 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { EntityRefLink } from './EntityRefLink'; + +describe('', () => { + it('renders link for entity in default namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders link for entity in other namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity and hides default kind', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render( + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity name in default namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'default', + name: 'software', + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders link for entity name in other namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity name and hides default kind', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render( + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link with custom children', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render( + + Custom Children + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('Custom Children')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); +}); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx new file mode 100644 index 0000000000..64b2bb8615 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, +} from '@backstage/catalog-model'; +import { Link } from '@material-ui/core'; +import React from 'react'; +import { generatePath } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import { entityRoute } from '../../routes'; +import { formatEntityRefTitle } from './format'; + +type EntityRefLinkProps = { + entityRef: Entity | EntityName; + defaultKind?: string; + children?: React.ReactNode; +}; + +// TODO: This component is private for now, as it should probably belong into +// some kind of helper module for the catalog plugin to avoid a dependency on +// the catalog plugin itself. +export const EntityRefLink = ({ + entityRef, + defaultKind, + children, +}: EntityRefLinkProps) => { + let kind; + let namespace; + let name; + + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } + + kind = kind.toLowerCase(); + + const routeParams = { + kind, + namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name, + }; + + // TODO: Use useRouteRef here to generate the path + return ( + + {children} + {!children && formatEntityRefTitle(entityRef, { defaultKind })} + + ); +}; diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx new file mode 100644 index 0000000000..9bc26b0954 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { EntityRefLinks } from './EntityRefLinks'; + +describe('', () => { + it('renders a single link', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders multiple links', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + { + kind: 'API', + namespace: 'default', + name: 'interface', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText(',')).toBeInTheDocument(); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + expect(getByText('api:interface')).toHaveAttribute( + 'href', + '/catalog/default/api/interface', + ); + }); +}); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx new file mode 100644 index 0000000000..3c8beaec6f --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity, EntityName } from '@backstage/catalog-model'; +import React from 'react'; +import { EntityRefLink } from './EntityRefLink'; + +type EntityRefLinksProps = { + entityRefs: (Entity | EntityName)[]; + defaultKind?: string; +}; + +// TODO: Move into a shared helper package +export const EntityRefLinks = ({ + entityRefs, + defaultKind, +}: EntityRefLinksProps) => { + return ( + <> + {entityRefs.map((r, i) => ( + + {i > 0 && ', '} + + + ))} + + ); +}; diff --git a/plugins/catalog/src/components/EntityRefLink/format.test.ts b/plugins/catalog/src/components/EntityRefLink/format.test.ts new file mode 100644 index 0000000000..142c914453 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/format.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { formatEntityRefTitle } from './format'; + +describe('formatEntityRefTitle', () => { + it('formats entity in default namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:software'); + }); + + it('formats entity in other namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:test/software'); + }); + + it('formats entity and hides default kind', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity, { defaultKind: 'Component' }); + expect(title).toEqual('test/software'); + }); + + it('formats entity name in default namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'default', + name: 'software', + }; + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:software'); + }); + + it('formats entity name in other namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:test/software'); + }); + + it('renders link for entity name and hides default kind', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName, { + defaultKind: 'component', + }); + expect(title).toEqual('test/software'); + }); +}); diff --git a/plugins/catalog/src/components/EntityRefLink/format.ts b/plugins/catalog/src/components/EntityRefLink/format.ts new file mode 100644 index 0000000000..28ba1bd22d --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/format.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + serializeEntityRef, +} from '@backstage/catalog-model'; + +export function formatEntityRefTitle( + entityRef: Entity | EntityName, + opts?: { defaultKind?: string }, +) { + const defaultKind = opts?.defaultKind; + let kind; + let namespace; + let name; + + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } + + if (namespace === ENTITY_DEFAULT_NAMESPACE) { + namespace = undefined; + } + + kind = kind.toLowerCase(); + + return `${serializeEntityRef({ + kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind, + name, + namespace, + })}`; +} diff --git a/plugins/catalog/src/components/EntityRefLink/index.ts b/plugins/catalog/src/components/EntityRefLink/index.ts new file mode 100644 index 0000000000..9e6e440514 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityRefLink } from './EntityRefLink'; +export { EntityRefLinks } from './EntityRefLinks'; +export { formatEntityRefTitle } from './format'; diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx new file mode 100644 index 0000000000..292cd37ede --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { EntityContext } from '../../hooks/useEntity'; +import { Entity } from '@backstage/catalog-model'; +import { EntitySwitch } from './EntitySwitch'; +import { isKind } from './conditions'; + +describe('EntitySwitch', () => { + it('should switch child when entity switches', () => { + const content = ( + + + + + + ); + + const rendered = render( + + {content} + , + ); + + expect(rendered.queryByText('A')).toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + expect(rendered.queryByText('C')).not.toBeInTheDocument(); + + rendered.rerender( + + {content} + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).toBeInTheDocument(); + expect(rendered.queryByText('C')).not.toBeInTheDocument(); + + rendered.rerender( + + {content} + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + expect(rendered.queryByText('C')).toBeInTheDocument(); + }); + + it('should switch child when filters switch', () => { + const entityContextValue = { + entity: { kind: 'component' } as Entity, + loading: false, + error: undefined, + }; + + const rendered = render( + + + + + + , + ); + + expect(rendered.queryByText('A')).toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + + rendered.rerender( + + + + + + , + ); + + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx new file mode 100644 index 0000000000..f36bc29cee --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ReactNode, + PropsWithChildren, + Children, + Fragment, + useMemo, + isValidElement, +} from 'react'; +import { useEntity } from '../../hooks/useEntity'; +import { Entity } from '@backstage/catalog-model'; + +const EntitySwitchCase = (_: { + if?: (entity: Entity) => boolean; + children: ReactNode; +}) => null; + +type SwitchCase = { + if?: (entity: Entity) => boolean; + children: JSX.Element; +}; + +function createSwitchCasesFromChildren(children: ReactNode): SwitchCase[] { + return Children.toArray(children).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSwitchCasesFromChildren(child.props.children); + } + + if (child.type !== EntitySwitchCase) { + throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`); + } + + const { if: condition, children } = child.props; + return [{ if: condition, children }]; + }); +} + +export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => { + const { entity } = useEntity(); + const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [ + children, + ]); + + const matchingCase = switchCases.find(switchCase => + switchCase.if ? switchCase.if(entity) : true, + ); + return matchingCase?.children ?? null; +}; + +EntitySwitch.Case = EntitySwitchCase; diff --git a/plugins/catalog/src/components/EntitySwitch/conditions.ts b/plugins/catalog/src/components/EntitySwitch/conditions.ts new file mode 100644 index 0000000000..8e4da19656 --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/conditions.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, ComponentEntity } from '@backstage/catalog-model'; + +function strCmp(a: string | undefined, b: string | undefined): boolean { + return Boolean(a && a?.toLowerCase() === b?.toLowerCase()); +} + +export function isKind(kind: string) { + return (entity: Entity) => strCmp(entity?.kind, kind); +} + +export function isComponentType(type: string) { + return (entity: Entity) => { + if (!strCmp(entity?.kind, 'component')) { + return false; + } + const componentEntity = entity as ComponentEntity; + return strCmp(componentEntity.spec.type, type); + }; +} + +export function isNamespace(namespace: string) { + return (entity: Entity) => strCmp(entity?.metadata?.namespace, namespace); +} diff --git a/plugins/catalog/src/components/EntitySwitch/index.ts b/plugins/catalog/src/components/EntitySwitch/index.ts new file mode 100644 index 0000000000..089f2a108c --- /dev/null +++ b/plugins/catalog/src/components/EntitySwitch/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntitySwitch } from './EntitySwitch'; +export { isKind, isNamespace, isComponentType } from './conditions'; diff --git a/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx b/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx index ec1857fcf8..970ce32ece 100644 --- a/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx +++ b/plugins/catalog/src/components/FavouriteEntity/FavouriteEntity.tsx @@ -39,7 +39,7 @@ export const favouriteEntityIcon = (isStarred: boolean) => * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities * @param props MaterialUI IconButton props extended by required `entity` prop */ -export const FavouriteEntity: React.FC = props => { +export const FavouriteEntity = (props: Props) => { const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); const isStarred = isStarredEntity(props.entity); return ( diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index fe1ba3a593..6a4593fb80 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -23,7 +23,7 @@ import { entityRoute, rootRoute } from '../routes'; import { CatalogPage } from './CatalogPage'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { EntityProvider } from './EntityProvider'; +import { EntityLoaderProvider } from './EntityLoaderProvider'; const DefaultEntityPage = () => ( @@ -50,7 +50,7 @@ const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { const { entity, loading, error } = useEntity(); // Loading and error states if (loading) return ; - if (error || (!loading && !entity)) return ; + if (error || !entity) return ; // Otherwise EntityPage provided from the App // Note that EntityPage will include EntityPageLayout already @@ -79,9 +79,9 @@ export const Router = ({ + - + } /> { const catalogApi = useApi(catalogApiRef); return useAsync(async () => { - const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const myLocation = + entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION]; if (!myLocation) { return []; } + + if (myLocation === 'bootstrap:bootstrap') { + throw new DeniedLocationException(myLocation); + } + const response = await catalogApi.getEntities({ - filter: { [LOCATION_ANNOTATION]: myLocation }, + filter: { + [`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: myLocation, + }, }); return response.items; }, [catalogApi, entity]); } -export const UnregisterEntityDialog: FC = ({ +export const UnregisterEntityDialog = ({ open, onConfirm, onClose, entity, -}) => { +}: Props) => { const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const catalogApi = useApi(catalogApiRef); const alertApi = useApi(alertApiRef); + const configApi = useApi(configApiRef); const removeEntity = async () => { const uid = entity.metadata.uid; @@ -82,13 +99,27 @@ export const UnregisterEntityDialog: FC = ({ Are you sure you want to unregister this entity? + {loading ? : null} + {error ? ( - {error.toString()} + {error.name === 'DeniedLocationException' ? ( + <> + You cannot unregister this entity, since it originates from a + protected Backstage configuration (location + {`"${(error as DeniedLocationException).locationName}"`}). If + you believe this is in error, please contact the{' '} + {configApi.getOptionalString('app.title') ?? 'Backstage'}{' '} + integrator. + + ) : ( + error.toString() + )} ) : null} + {entities?.length ? ( <> @@ -96,9 +127,10 @@ export const UnregisterEntityDialog: FC = ({
    - {entities.map(e => ( -
  • {e.metadata.name}
  • - ))} + {entities.map(e => { + const fullName = formatEntityRefTitle(e); + return
  • {fullName}
  • ; + })}
@@ -107,16 +139,21 @@ export const UnregisterEntityDialog: FC = ({
  • - {entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]} + { + entities[0]?.metadata.annotations?.[ + ORIGIN_LOCATION_ANNOTATION + ] + }
+ + To undo, just re-register the entity in Backstage. + ) : null} - - To undo, just re-register the entity in Backstage. -
+