diff --git a/.changeset/giant-rice-jump.md b/.changeset/giant-rice-jump.md deleted file mode 100644 index 20a574176e..0000000000 --- a/.changeset/giant-rice-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-api': patch ---- - -Fix for GitHub and SAML auth not properly updating session state when already logged in. diff --git a/.changeset/little-lemons-hope.md b/.changeset/little-lemons-hope.md deleted file mode 100644 index e2a7f747d6..0000000000 --- a/.changeset/little-lemons-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -GitLab preparer uses the right token (primarily the same one as the publisher, falling back to the integrations token) diff --git a/.changeset/orange-dancers-occur.md b/.changeset/orange-dancers-occur.md deleted file mode 100644 index 0283ffb634..0000000000 --- a/.changeset/orange-dancers-occur.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': minor ---- - -Add search plugin to default template for CLI created apps diff --git a/.changeset/quiet-pants-happen.md b/.changeset/quiet-pants-happen.md deleted file mode 100644 index 165e3ae12c..0000000000 --- a/.changeset/quiet-pants-happen.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/test-utils': patch -'@backstage/plugin-graphiql': patch ---- - -Update to use new plugin extension API diff --git a/.changeset/short-dancers-explode.md b/.changeset/short-dancers-explode.md deleted file mode 100644 index 56fee63fe7..0000000000 --- a/.changeset/short-dancers-explode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-api': patch ---- - -Introduce new plugin extension API diff --git a/.changeset/slimy-penguins-sing.md b/.changeset/slimy-penguins-sing.md deleted file mode 100644 index 62b27a1ce6..0000000000 --- a/.changeset/slimy-penguins-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Added configuration schema for the commonly used properties diff --git a/.changeset/warm-moose-applaud.md b/.changeset/warm-moose-applaud.md deleted file mode 100644 index 70ae20711a..0000000000 --- a/.changeset/warm-moose-applaud.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Clearer error message when preparer or publisher type can't be determined. diff --git a/.changeset/young-pumas-clap.md b/.changeset/young-pumas-clap.md new file mode 100644 index 0000000000..2c7b1986b9 --- /dev/null +++ b/.changeset/young-pumas-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +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 diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index b4be37c057..f9d5024af4 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -36,6 +36,7 @@ Codecov codehilite Codehilite codeowners +composability composable config Config @@ -49,6 +50,7 @@ dataflow deadnaming destructured dev +devops devs dhenneke discoverability @@ -62,7 +64,6 @@ Docusaurus Dominik dtuite dzolotusky -eg Ek env Env @@ -134,12 +135,14 @@ neuro newrelic nginx Niklas +nodegit nohoist nonces npm +nvarchar nvm oauth -Oauth +OAuth oidc Okta Oldsberg @@ -158,7 +161,6 @@ prebaked preconfigured prepack Preprarer -Prerequisities productional Protobuf proxying @@ -192,7 +194,6 @@ semlas semver Serverless Sinon -smartsymobls Snyk sourcemaps sparklines @@ -219,7 +220,6 @@ Templater templaters Templaters Thauer -theres toc tolerations Tolerations @@ -237,6 +237,7 @@ 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/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..d49f82c235 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -70,21 +70,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 +77,85 @@ 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: + if: contains(github.event.commits.*.author.username, 'backstage-service') && contains(github.event.head_commit.message, 'from backstage/changeset-release/master') + needs: build + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + env: + CI: 'true' + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - 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/nightly.yml b/.github/workflows/nightly.yml index 0dc40d2c40..846882d853 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,6 +55,7 @@ jobs: - name: tsc run: yarn tsc + - name: build run: yarn build diff --git a/ADOPTERS.md b/ADOPTERS.md index 3d5914d97b..493bf499af 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -16,3 +16,4 @@ | [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. | 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/README.md b/README.md index a4b21539b5..d2e01195d4 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. diff --git a/app-config.yaml b/app-config.yaml index f6f71478d0..48869cc0a0 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -76,7 +76,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. sentry: organization: my-company @@ -206,6 +206,13 @@ 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 diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index 18a6c5a4f0..c3460f98db 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/docs/assets/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/cli/commands.md b/docs/cli/commands.md index c505bf8ae7..726f2a922c 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -28,6 +28,7 @@ app:diff Diff an existing app with the creation template app:serve Serve an app for local development backend:build Build a backend plugin +backend:bundle Bundle the backend into a deployment archive backend:build-image Bundles the package into a docker image backend:dev Start local development server with HMR for the backend @@ -166,7 +167,7 @@ Options: ## backend:build -Scope: `backend`, `backend-plugin` +Scope: `backend-plugin` This builds a backend package for publishing and use in production. The build output is written to `dist/`. Be sure to list any additional file that the @@ -180,6 +181,52 @@ Options: -h, --help display help for command ``` +## backend:bundle + +Scope: `backend` + +Bundle the backend and all of its local dependencies into a deployment archive. +The archive is written to `dist/bundle.tar.gz`, and contains the packaged +version of all dependencies of the target package, along with the target package +itself. The layout of the packages in the archive is the same as the directory +layout in the target monorepo, and the bundle also contains the root +`package.json` and `yarn.lock`. + +To use the bundle, extract it into a target directory, run +`yarn install --production`, and then start the target backend package using for +example `node package/backend`. + +The `dist/bundle.tar.gz` is accompanied by a `dist/skeleton.tar.gz`, which has +the same layout, but only contains `package.json` files and `yarn.lock`. This +can be used to run a `yarn install` in environments that will benefit from the +caching that this enables, such as Docker image builds. To use the skeleton +archive, simply extract it first, run install, and then extract the main bundle. + +The following is an example of a `Dockerfile` that can be used to package the +output of `backstage-cli backend:bundle` into an image: + +```Dockerfile +FROM node:14-buster +WORKDIR /app + +ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ + +CMD node packages/backend +``` + +```text +Usage: backstage-cli backend:bundle [options] + +Bundle the backend into a deployment archive + +Options: + --build-dependencies Build all local package dependencies before bundling the backend + -h, --help display help for command +``` + ## backend:build-image Scope: `backend` diff --git a/docs/conf/defining.md b/docs/conf/defining.md index 358498929a..34b9b11977 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -36,6 +36,10 @@ export interface Config { * @visibility frontend */ baseUrl: string; + + // Use @items. to assign annotations to primitive array items + /** @items.visibility frontend */ + myItems: string[]; }; } ``` diff --git a/docs/conf/writing.md b/docs/conf/writing.md index f28dd5b2d3..7e8402b6f6 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -141,16 +141,17 @@ itself: $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 in JSON data from an external file. +It's able to load and parse data from `.json`, `.yml`, and `.yaml` files. It's +also possible to include a url fragment (`#`) to point to a value at the given +path in the file. + +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: diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index 53f49d5df9..d797e87499 100644 --- a/docs/features/software-catalog/system-model.md +++ b/docs/features/software-catalog/system-model.md @@ -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. diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 50cadc7b27..82db4e15ff 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -45,8 +45,6 @@ about TechDocs and the philosophy in its [v2]: https://github.com/backstage/backstage/milestone/22 [v3]: https://github.com/backstage/backstage/milestone/17 - - ## Use Cases #### TechDocs V.0 @@ -110,12 +108,12 @@ providers are used. | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | -| File Storage Provider | Support Status | Track status | -| --------------------------------- | -------------- | ----------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | | -| Google Cloud Storage (GCS) | Yes ✅ | | -| Amazon Web Services (AWS) S3 | No ❌ | [#3714](https://github.com/backstage/backstage/issues/3714) | -| Azure Storage | No ❌ | | +| File Storage Provider | Support Status | +| --------------------------------- | ----------------------------------------------------------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 3969002a46..7c35fbc943 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -141,13 +141,13 @@ Status of all the features mentioned above. **In place ✅** - Basic setup with techdocs-backend file server as storage. +- Basic setup with cloud storage solution. **Work in progress 🚧** -- 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. **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/configuration.md b/docs/features/techdocs/configuration.md index b3cb349778..7a0a4dbbbf 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -13,18 +13,15 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. requestUrl: http://localhost:7000/api/techdocs - # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware # to serve files from either a local directory or an External storage provider. storageUrl: http://localhost:7000/api/techdocs/static/docs - # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running @@ -34,7 +31,6 @@ techdocs: generators: techdocs: 'docker' - # techdocs.builder can be either 'local' or 'external. # If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage # and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture. @@ -44,30 +40,42 @@ techdocs: builder: 'local' - # techdocs.publisher is used to configure the Storage option, whether you want to use the local filesystem to store generated docs # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - - # techdocs.publisher.type can be - 'local' or 'googleGcs' (awsS3, azureStorage, etc. to be available as well). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future). # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. + # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. type: 'local' - # Required when techdocs.publisher.type is set to 'googleGcs'. Skip otherwise. googleGcs: # An API key is required to write to a storage bucket. credentials: - $file: '/path/to/google_application_credentials.json', + $file: '/path/to/google_application_credentials.json' # Your GCP Project ID where the Cloud Storage Bucket is hosted. projectId: 'gcp-project-id' # Cloud Storage Bucket Name - bucketName: 'techdocs-storage', + bucketName: 'techdocs-storage' + # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. + + awsS3: + # An API key is required to write to a storage bucket. + credentials: + accessKeyId: + $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + secretAccessKey: + $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + region: + $env: AWSS3_REGION + + # AWS S3 Bucket Name + bucketName: 'techdocs-storage' ``` 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 c82bc8b95b..e1ddfd23f9 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -72,12 +72,8 @@ add the following ```typescript import { createRouter, - DirectoryPreparer, Preparers, Generators, - TechdocsGenerator, - CommonGitPreparer, - UrlPreparer, Publisher, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; @@ -90,30 +86,25 @@ export default async function createPlugin({ reader, }: PluginEnvironment) { // Preparers are responsible for fetching source files for documentation. - const preparers = new Preparers(); - - 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 preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); // Generators are used for generating documentation sites. - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); - generators.register('techdocs', techdocsGenerator); + const generators = await Generators.fromConfig(config, { + logger, + }); - // Publishers are used for + // Publisher is used for // 1. Publishing generated files to storage // 2. Fetching files from storage and passing them to TechDocs frontend. - const publisher = Publisher.fromConfig(config, logger, discovery); + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); - // Docker client used by the generators. + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index c7b5ec9a9f..c05346ca4a 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -93,3 +93,175 @@ techdocs: Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to store the static generated documentation files. + +## Configuring AWS S3 Bucket with TechDocs + +Follow the +[official AWS S3 documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html) +for the latest instructions on the following steps involving AWS S3. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'awsS3'`. + +```yaml +techdocs: + publisher: + type: 'awsS3' +``` + +**2. AWS Policies** + +AWS Policies lets you **control access** to Amazon Web Services (AWS) products +and resources. +Here we will use a user policy **and** a bucket policy to show you the different +possibilities you have but you can use only one. + +AWS S3 + +This is an example of how you can manage your policies: + +a. Admin user creates a **bucket policy** granting a set of permissions to our +TechDocs user. + +b. Admin user attaches a **user policy** to the TechDocs user granting +additional permissions. + +c. TechDocs User then tries permissions granted via both the **bucket** policy +and the **user** policy. + +**2.1 Creation** + +**2.1.1 Create an Admin user** (if you don't have one yet) + +Create an **administrator user** account `ADMIN_USER` and grant it administrator +privileges by attaching a user policy giving the account **full access**. +Note down the Admin User credentials and IAM User Sign-In URL as you will need +to use this information in the next step. + +**2.1.2 Create an AWS S3 Bucket** + +Using the credentials of your Admin User `ADMIN_USER`, and the special IAM user +sign-in URL, create a dedicated **bucket** for TechDocs sites. techdocs-backend +will publish documentation to this bucket. TechDocs will fetch files from here +to serve documentation in Backstage. + +Set the name of the bucket to `techdocs.publisher.awsS3.bucketName`. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**2.1.3 Create the `TechDocs` user** + +This user will be used to interact with your bucket, it will only have +permissions to **get - put** objects. + +In the IAM console, do the following: + +- Create a new user, `TechDocs` +- Note down the TechDocs User credentials +- Note down the Amazon Resource Name (ARN) for the TechDocs user. In the IAM + console, select the TechDocs user, and you can find the user ARN in the + Summary tab. + +**2.2 Attach policies** + +Remember that you can use Bucket policy **or** User policy. +Just make sure that you grant all the permissions to the TechDocs user: +`3:PutObject`, `s3:GetObject`, `s3:ListBucket` and `s3:GetBucketLocation`. + +**2.2.1 Create the bucket policy** + +You now have to attach the following policy to your bucket in the Permission +section: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "statement1", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" + }, + "Action": ["s3:GetBucketLocation", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket"] + }, + { + "Sid": "statement2", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" + }, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] + } + ] +} +``` + +- The first statement grants **TechDocs User** the bucket operation permissions + `s3:GetBucketLocation` and `s3:ListBucket` which are permissions required by + the console. +- The second statement grants the `s3:GetObject` permission. + (**NOTE :** if you do not use the user policy defined below you must also add + the `s3:PutObject` permission to allow the TechDocs user to add objects.) + +**2.2.2 Create the user policy** + +Create an inline policy for the TechDocs user by using the following policy: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PermissionForObjectOperations", + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] + } + ] +} +``` + +See more details in the section +[Working with Inline Policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_manage.html). + +Now you need to fill in the environment variables with the `TechDocs` User +credentials. You can also specify a region if you want to accesses the resources +in a specific region. Otherwise no region will be selected by default. + +```properties +TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL="TECHDOCS_ACCESS_KEY_ID" +TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL="TECHDOCS_SECRET_ACCESS_KEY" +AWSS3_REGION="" // Optional +``` + +Make it available in your Backstage server and/or your local development server +and set it in the app config techdocs.publisher.awsS3. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + credentials: + accessKeyId: + $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + secretAccessKey: + $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + region: + $env: AWSS3_REGION +``` + +**3. That's it!** + +Your Backstage app is now ready to use AWS S3 for TechDocs, to store the static +generated documentation files. diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index 5439b838bd..b414e62853 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -9,7 +9,7 @@ Powered by a centralized service catalog, Backstage restores order to your micro [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/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/testing.md b/docs/plugins/testing.md index b564fd732c..30c3bf0ecc 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -16,15 +16,15 @@ frameworks and libraries like [Mocha](https://mochajs.org/), Running all tests: - yarn test-react + yarn test Running an individual test (e.g. `MyComponent.test.js`): - yarn test-react MyComponent + yarn test MyComponent To run both `MyComponent.test.js` and `MyControl.test.js` suite of tests: - yarn test-react MyCo + yarn test MyCo Note: if `console.logs` are not appearing, run only the individual test you are working on. @@ -52,12 +52,12 @@ render React components. TODO. -# Writing Unit Tests +## Writing Unit Tests The following principles are good guides for determining if you are writing high quality frontend unit tests. -## Bad Unit Test Principle +### Bad Unit Test Principle > No unit test is better than a bad one. @@ -69,7 +69,7 @@ Writing a poor unit test: - Adds to future work by requiring updates to the unit test for irrelevant code changes. -## Input/Output Principle +### Input/Output Principle > A unit test verifies an output matches an expected input. @@ -77,7 +77,7 @@ For backend, this would be that when you provide configuration X, then the object responds with Y. For frontend, this would be that when you provide properties X to a component, then the visual functionality responds with Y. -## Blackbox Principle +### Blackbox Principle > A good unit test does not tell the object how it should do its job but should > only compare inputs to outputs. @@ -86,7 +86,7 @@ Consider a unit test for a form. A good unit test would not test the order of the form fields. Instead, it would verify that the inputs to the form fields lead to a certain backend call when submit is clicked. -## Scalability Principle +### Scalability Principle > Unit test quality is directly proportionate to how much code can change > without having to touch the unit test. @@ -97,7 +97,7 @@ to the code, you have to update the unit test. A good unit test suite allows a lot of flexibility in _how_ the code is written so that future refactoring can occur without having to touch the original unit tests. -## Increasing Complexity Principle +### Increasing Complexity Principle > The ordering of unit tests in a suite should proceed from least specific to > most specific. @@ -116,7 +116,7 @@ throwing an error saying that output was incorrect will lead the next developer into thinking they may have broken the entire functionality of the object rather than simply letting them know they had an invalid input. -## Broken Functionality Principle +### Broken Functionality Principle > Generally, a unit test should not test exactly how the output appears, it > should test that the functionality has an expected _general_ response to an @@ -131,7 +131,7 @@ test a slightly different color on the button the unit test will break. A better unit test would verify that the button's CSS classname is assigned properly on hover or test for something completely different. -## Example: Loading Indicator +### Example: Loading Indicator A classic unit test on frontends is verifying a loading indicator displays when a backend request is being made. @@ -192,11 +192,14 @@ returns a result or displays an error or console message, like so: **`StringUtil ellipsis`** - export function ellipsis(text, maxLength, midCharIx = 0, ellipsis = '...') { - // Do something blackbox. We should not care about the internals, only inputs and outputs. - ... - return someFinalValue; - } +```js +export function ellipsis(text, maxLength, midCharIx = 0, ellipsis = '...') { + // Do something blackbox. We should not care about the internals, + // only inputs and outputs. + ... + return someFinalValue; +} +``` There are four things to test for in a utility function: @@ -207,30 +210,36 @@ There are four things to test for in a utility function: > Handle Invalid Input (handle thrown errors): - it('Throws an error on improper arguments', () => { - expect(() => { - ellipsis(); - }).toThrowError('Expected \'text\' to be defined'); - }); +```js +it('Throws an error on improper arguments', () => { + expect(() => { + ellipsis(); + }).toThrowError("Expected 'text' to be defined"); +}); +``` > Verify default input arguments: - it('Works with defaults', () => { - expect(ellipsis('Hello world', 3)).toBe('Hel...'); - expect(ellipsis('', 3)).toBe(''); - expect(ellipsis('H', 3)).toBe('H'); - expect(ellipsis('Hello', 5)).toBe('Hello'); - }); +```js +it('Works with defaults', () => { + expect(ellipsis('Hello world', 3)).toBe('Hel...'); + expect(ellipsis('', 3)).toBe(''); + expect(ellipsis('H', 3)).toBe('H'); + expect(ellipsis('Hello', 5)).toBe('Hello'); +}); +``` > Verify output for expected input arguments: This is especially true for edge cases! - it('Works with midCharIx', () => { - expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); - expect(ellipsis('', 3, 6)).toBe(''); - expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); - }); +```js +it('Works with midCharIx', () => { + expect(ellipsis('Hello world', 3, 6)).toBe('...o w...'); + expect(ellipsis('', 3, 6)).toBe(''); + expect(ellipsis('Backstage is amazing', 4, 10)).toBe('...e is...'); +}); +``` ## Non-React Classes @@ -372,4 +381,4 @@ IDE. In most cases, we have found that using `console.log` works well. Note: if your console.logs are not being displayed, focus your specific unit -test from the command line by running them like so `yarn test-react MyTest`. +test from the command line by running them like so `yarn test MyTest`. diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md index bbba127c9a..a30288e85e 100644 --- a/docs/support/project-structure.md +++ b/docs/support/project-structure.md @@ -41,8 +41,8 @@ the code. 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. diff --git a/local.yaml b/local.yaml new file mode 100644 index 0000000000..afc4c21cfe --- /dev/null +++ b/local.yaml @@ -0,0 +1,14 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: kafka-test + description: Kafka Test + annotations: + kafka.apache.org/consumer-group: consumer + tags: + - java + - data +spec: + type: service + lifecycle: experimental + owner: architecture 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/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/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/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/pager-duty.yaml b/microsite/data/plugins/pager-duty.yaml index e8a0a1b0d9..d0006c09ec 100644 --- a/microsite/data/plugins/pager-duty.yaml +++ b/microsite/data/plugins/pager-duty.yaml @@ -1,5 +1,5 @@ --- -title: PageDuty +title: PagerDuty author: Spotify authorUrl: https://github.com/spotify category: Monitoring diff --git a/microsite/package.json b/microsite/package.json index e1860b8314..975bec3149 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@spotify/prettier-config": "^9.0.0", "docusaurus": "^2.0.0-alpha.378053ac5", - "js-yaml": "^3.14.1", + "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..c26fc9a8e8 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 - - .) + . diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 0422bfab63..395aba8cd0 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -27,8 +27,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. 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; diff --git a/microsite/yarn.lock b/microsite/yarn.lock index e41db2a665..356585cf3e 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -1124,6 +1124,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" @@ -3921,7 +3926,7 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.1, js-yaml@^3.14.1, js-yaml@^3.8.1: +js-yaml@^3.13.1, js-yaml@^3.8.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== @@ -3929,6 +3934,13 @@ js-yaml@^3.13.1, js-yaml@^3.14.1, js-yaml@^3.8.1: 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" diff --git a/package.json b/package.json index caf19b0311..8376915729 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "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}' && yarn install --frozen-lockfile", + "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", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 799895ef56..bd159afb9e 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,50 @@ # example-app +## 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 diff --git a/packages/app/package.json b/packages/app/package.json index 66471b981c..43113bb464 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,40 +1,39 @@ { "name": "example-app", - "version": "0.2.7", + "version": "0.2.9", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.2", - "@backstage/core": "^0.4.1", + "@backstage/cli": "^0.4.5", + "@backstage/core": "^0.4.2", "@backstage/plugin-api-docs": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/plugin-catalog-import": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.10", + "@backstage/plugin-catalog-import": "^0.3.3", "@backstage/plugin-circleci": "^0.2.5", "@backstage/plugin-cloudbuild": "^0.2.5", - "@backstage/plugin-cost-insights": "^0.5.2", + "@backstage/plugin-cost-insights": "^0.5.5", "@backstage/plugin-explore": "^0.2.2", "@backstage/plugin-gcp-projects": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.5", + "@backstage/plugin-github-actions": "^0.2.6", "@backstage/plugin-gitops-profiles": "^0.2.2", - "@backstage/plugin-graphiql": "^0.2.2", + "@backstage/plugin-graphiql": "^0.2.3", + "@backstage/plugin-org": "^0.3.2", "@backstage/plugin-jenkins": "^0.3.4", "@backstage/plugin-kafka": "^0.1.0", "@backstage/plugin-kubernetes": "^0.3.3", "@backstage/plugin-lighthouse": "^0.2.6", "@backstage/plugin-newrelic": "^0.2.2", - "@backstage/plugin-org": "^0.3.2", - "@backstage/plugin-pagerduty": "0.2.3", + "@backstage/plugin-pagerduty": "0.2.5", "@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", - "@backstage/plugin-sentry": "^0.3.1", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.0", + "@backstage/plugin-scaffolder": "^0.3.6", + "@backstage/plugin-sentry": "^0.3.2", + "@backstage/plugin-search": "^0.2.5", + "@backstage/plugin-tech-radar": "^0.3.2", + "@backstage/plugin-techdocs": "^0.5.1", "@backstage/plugin-user-settings": "^0.2.3", "@backstage/plugin-welcome": "^0.2.3", - "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -54,6 +53,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/test-utils": "^0.1.6", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a58bf45f9c..627b5d19aa 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -29,7 +29,7 @@ import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; @@ -81,7 +81,7 @@ const AppRoutes = () => ( path="/tech-radar" element={} /> - } /> + } /> } /> { - 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/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ca934ee42c..ea716a1d4a 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -47,13 +47,7 @@ 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 = getAzureFileFetchUrl(url); 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/catalog/src/components/AboutCard/IconLinkVertical/index.ts b/packages/backend-common/src/scm/index.ts similarity index 91% rename from plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts rename to packages/backend-common/src/scm/index.ts index ddc146dbd8..e967fffb44 100644 --- a/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts +++ b/packages/backend-common/src/scm/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { IconLinkVertical } from './IconLinkVertical'; +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 46bf23beef..9c5ac20fa3 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -145,7 +145,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } - start(): Promise { + async start(): Promise { const app = express(); const { port, @@ -168,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.ts b/packages/backend-common/src/service/lib/config.ts index ea1a2b0887..4547c41b7b 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -47,14 +47,14 @@ export type CertificateReferenceOptions = { }; export type CertificateSigningOptions = { - algorithm: string; + algorithm?: string; size?: number; days?: number; - attributes?: CertificateAttributes; + attributes: CertificateAttributes; }; export type CertificateAttributes = { - commonName?: string; + commonName: string; }; /** @@ -193,8 +193,26 @@ 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 commonName; + try { + commonName = new URL(baseUrl).hostname; + } catch (error) { + throw new Error(`Invalid backend.baseUrl "${baseUrl}"`); + } + return { + certificate: { + attributes: { + commonName, + }, + }, + }; + } + + 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..656f160c31 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -13,11 +13,16 @@ * 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'; +import { CertificateSigningOptions, HttpsSettings } from './config'; + +const ALMOST_MONTH_IN_MS = 25 * 24 * 60 * 60 * 1000; /** * Creates a Http server instance based on an Express application. @@ -45,48 +50,152 @@ 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; + // TODO(Rugvip): remove support for generated certificate params and make this a more straightforward check + if (signingOptions?.attributes) { + credentials = await getGeneratedCertificate(signingOptions, logger); } else { - logger?.info('Bootstrapping cert from config'); + logger?.info('Loading certificate from config'); - credentials.key = signingOptions?.key; - credentials.cert = signingOptions?.cert; + credentials = { + key: signingOptions?.key, + cert: signingOptions?.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( + options: CertificateSigningOptions, + logger?: Logger, +) { + if (options?.algorithm) { + logger?.warn( + 'Certificate generation configuration with parameters in backend.https.certificate is deprecated, set backend.https = true instead', + ); + } + + 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(options); + await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); + return newCert; +} + +async function createCertificate(options: CertificateSigningOptions) { + const attributes: Array = Object.entries( + options.attributes, + ).map(([name, value]) => ({ name, value })); + + const params = { + algorithm: options?.algorithm || 'sha256', + keySize: options?.size || 2048, + days: options?.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: [ + { + type: 2, // DNS + value: 'localhost', + }, + { + type: 2, + value: 'localhost.localdomain', + }, + { + type: 2, + value: '[::1]', + }, + { + type: 7, // IP + ip: '127.0.0.1', + }, + { + type: 7, + ip: 'fe80::1', + }, + ...(options.attributes.commonName + ? [ + { + type: 2, // DNS + value: options.attributes.commonName, + }, + ] + : []), + ], + }, + ], + }; + + 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/CHANGELOG.md b/packages/backend/CHANGELOG.md index 3f053accfa..2740b98937 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,30 @@ # example-backend +## 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 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 707c14e33c..dc3cf75367 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.7", + "version": "0.2.10", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,19 +32,19 @@ "@backstage/config": "^0.1.2", "@backstage/plugin-app-backend": "^0.3.3", "@backstage/plugin-auth-backend": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.0", + "@backstage/plugin-catalog-backend": "^0.5.1", "@backstage/plugin-graphql-backend": "^0.1.4", "@backstage/plugin-kubernetes-backend": "^0.2.3", "@backstage/plugin-kafka-backend": "^0.1.0", "@backstage/plugin-proxy-backend": "^0.2.3", "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder-backend": "^0.3.5", - "@backstage/plugin-techdocs-backend": "^0.4.0", + "@backstage/plugin-scaffolder-backend": "^0.4.0", + "@backstage/plugin-techdocs-backend": "^0.5.0", "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.7", + "example-app": "^0.2.8", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -54,7 +54,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.3", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 4822de9c5c..afe92cc347 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -15,12 +15,8 @@ */ import { createRouter, - DirectoryPreparer, Preparers, Generators, - TechdocsGenerator, - CommonGitPreparer, - UrlPreparer, Publisher, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; @@ -33,30 +29,25 @@ export default async function createPlugin({ reader, }: PluginEnvironment) { // Preparers are responsible for fetching source files for documentation. - const preparers = new Preparers(); - - 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 preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); // Generators are used for generating documentation sites. - const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); - generators.register('techdocs', techdocsGenerator); + const generators = await Generators.fromConfig(config, { + logger, + }); - // Publishers are used for + // Publisher is used for // 1. Publishing generated files to storage // 2. Fetching files from storage and passing them to TechDocs frontend. - const publisher = Publisher.fromConfig(config, logger, discovery); + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); - // Docker client used by the generators. + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8f73247301..8b503c6941 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,29 @@ # @backstage/cli +## 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index c284b81e2e..6ce4ba3715 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.4.2", + "version": "0.4.5", "private": false, "publishConfig": { "access": "public" @@ -30,7 +30,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.0", + "@backstage/config-loader": "^0.4.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -66,7 +66,7 @@ "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", "fork-ts-checker-webpack-plugin": "^4.0.5", @@ -111,11 +111,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.1", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/core": "^0.4.3", + "@backstage/dev-utils": "^0.1.7", + "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@types/diff": "^4.0.2", "@types/fs-extra": "^9.0.1", @@ -125,7 +125,6 @@ "@types/mini-css-extract-plugin": "^0.9.1", "@types/mock-fs": "^4.13.0", "@types/node": "^13.7.2", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "@types/rollup-plugin-peer-deps-external": "^2.2.0", @@ -182,7 +181,7 @@ "visibility": "frontend", "description": "The host that the frontend should be bound to. Only used for local development." }, - "post": { + "port": { "type": "number", "visibility": "frontend", "description": "The port that the frontend should be bound to. Only used for local development." diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts index 4d298f3735..25322045e1 100644 --- a/packages/cli/src/commands/backend/bundle.ts +++ b/packages/cli/src/commands/backend/bundle.ts @@ -38,7 +38,7 @@ export default async (cmd: Command) => { try { await createDistWorkspace([pkg.name], { targetDir: tmpDir, - buildDependencies: Boolean(cmd.build), + buildDependencies: Boolean(cmd.buildDependencies), buildExcludes: [pkg.name], parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), skeleton: SKELETON_FILE, diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index e090c454f9..9dce57813b 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -45,9 +45,12 @@ export function registerCommands(program: CommanderStatic) { .action(lazy(() => import('./backend/build').then(m => m.default))); program - .command('backend:__experimental__bundle__', { hidden: true }) - .description('Bundle all backend packages into dist-workspace') - .option('--build', 'Build packages before packing them into the image') + .command('backend:bundle') + .description('Bundle the backend into a deployment archive') + .option( + '--build-dependencies', + 'Build all local package dependencies before bundling the backend', + ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); program @@ -212,6 +215,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..164a202385 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); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 441f65f353..8b32cd5768 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; }; @@ -69,6 +71,7 @@ export default async () => { name, location: pkg.location, range: `^${target}`, // TODO(Rugvip): Option to use something else than ^? + target, }), ); } @@ -98,9 +101,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 +118,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 +128,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 +164,42 @@ export default async () => { }); console.log(); - console.log("Running 'yarn install' to install new versions"); + console.log( + `Running ${chalk.blue('yarn install')} to install new versions`, + ); console.log(); await run('yarn', ['install']); + + if (breakingUpdates.size > 0) { + console.log(); + console.log( + chalk.yellow('⚠️ The following packages may have breaking changes:'), + ); + console.log(); + + for (const name of Array.from(breakingUpdates.keys()).sort()) { + console.log(` ${chalk.yellow(name)}`); + + let path; + if (name.startsWith('@backstage/plugin-')) { + path = `plugins/${name.replace('@backstage/plugin-', '')}`; + } else if (name.startsWith('@backstage/')) { + path = `packages/${name.replace('@backstage/', '')}`; + } + if (path) { + // TODO(Rugvip): Grab these URLs and paths from package.json, possibly verify existence + // Possibly invent new "changelog" field in package.json or some sh*t. + console.log( + ` https://github.com/backstage/backstage/blob/master/${path}/CHANGELOG.md`, + ); + } + console.log(); + } + } else { + console.log(); + } + + console.log(chalk.green('Version bump complete!')); } console.log(); diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 2e7847fdb1..a236e87806 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -62,7 +62,7 @@ export const makeConfigs = async ( output.push({ dir: 'dist', entryFileNames: 'index.cjs.js', - chunkFileNames: 'cjs/[name]-[hash].js', + chunkFileNames: 'cjs/[name]-[hash].cjs.js', format: 'commonjs', sourcemap: true, }); @@ -71,7 +71,7 @@ export const makeConfigs = async ( output.push({ dir: 'dist', entryFileNames: 'index.esm.js', - chunkFileNames: 'esm/[name]-[hash].js', + chunkFileNames: 'esm/[name]-[hash].esm.js', format: 'module', sourcemap: true, }); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts index 53512567eb..f3906bfd0d 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts @@ -14,20 +14,27 @@ * 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('/root/repo/node_modules', [ - { - name: 'a', - location: '/root/external-a', - }, - { - name: '@s/b', - location: '/root/external-b', - }, - ]); + 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(); @@ -50,10 +57,10 @@ describe('LinkedPackageResolvePlugin', () => { const callbackX = jest.fn(); tap( { - request: '/root/repo/package/x/src/module.ts', - path: '/root/repo/package/x/src', + request: path.resolve(root, 'repo/package/x/src/module.ts'), + path: path.resolve(root, 'repo/package/x/src'), context: { - issuer: '/root/repo/package/x/src/index.ts', + issuer: path.resolve(root, 'repo/package/x/src/index.ts'), }, }, 'some-context', @@ -77,14 +84,31 @@ describe('LinkedPackageResolvePlugin', () => { expect(callbackFalse).toHaveBeenCalledWith(); expect(doResolve).toHaveBeenCalledTimes(0); + // Internal modules with a path prefix of an external module + const callbackY = jest.fn(); + tap( + { + request: path.resolve(root, 'external-aa/src/module.ts'), + path: path.resolve(root, 'external-aa/src'), + context: { + issuer: path.resolve(root, 'external-aa/src/index.ts'), + }, + }, + 'some-context', + callbackY, + ); + expect(callbackY).toHaveBeenCalledTimes(1); + expect(callbackY).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + // External modules have their path and issuer context rewritten, but not the request const callbackA = jest.fn(); tap( { - request: '/root/external-a/src/module.ts', - path: '/root/external-a/src', + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'external-a/src'), context: { - issuer: '/root/external-a/src/index.ts', + issuer: path.resolve(root, 'external-a/src/index.ts'), }, }, 'some-context', @@ -95,13 +119,16 @@ describe('LinkedPackageResolvePlugin', () => { expect(doResolve).toHaveBeenCalledWith( resolver.hooks.resolve, { - request: '/root/external-a/src/module.ts', - path: '/root/repo/node_modules/a/src', + request: path.resolve(root, 'external-a/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/a/src'), context: { - issuer: '/root/repo/node_modules/a/src/index.ts', + issuer: path.resolve(root, 'repo/node_modules/a/src/index.ts'), }, }, - 'resolve /root/external-a/src/module.ts in /root/repo/node_modules/a', + `resolve ${path.resolve( + root, + 'external-a/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/a')}`, 'some-context', callbackA, ); @@ -110,8 +137,8 @@ describe('LinkedPackageResolvePlugin', () => { const callbackB = jest.fn(); tap( { - request: '/root/external-b/src/module.ts', - path: '/root/external-b/src', + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'external-b/src'), context: { issuer: false, }, @@ -124,13 +151,16 @@ describe('LinkedPackageResolvePlugin', () => { expect(doResolve).toHaveBeenLastCalledWith( resolver.hooks.resolve, { - request: '/root/external-b/src/module.ts', - path: '/root/repo/node_modules/@s/b/src', + request: path.resolve(root, 'external-b/src/module.ts'), + path: path.resolve(root, 'repo/node_modules/@s/b/src'), context: { issuer: false, }, }, - 'resolve /root/external-b/src/module.ts in /root/repo/node_modules/@s/b', + `resolve ${path.resolve( + root, + 'external-b/src/module.ts', + )} in ${path.resolve(root, 'repo/node_modules/@s/b')}`, 'some-context', callbackB, ); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 86269a5828..0478337c61 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -16,6 +16,7 @@ import { resolve as resolvePath } from 'path'; import { ResolvePlugin } from 'webpack'; +import { isChildPath } from './paths'; import { LernaPackage } from './types'; // Enables proper resolution of packages when linking in external packages. @@ -40,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin { callback: () => void, ) => { const pkg = this.packages.find( - pkg => data.path && data.path.startsWith(pkg.location), + pkg => data.path && isChildPath(pkg.location, data.path), ); if (!pkg) { callback(); diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 3c336401ee..c53a8329f5 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -24,7 +24,7 @@ import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths } from './paths'; +import { BundlingPaths, isChildPath } from './paths'; import { transforms } from './transforms'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; @@ -87,7 +87,9 @@ export async function createConfig( const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); @@ -199,7 +201,9 @@ export async function createBackendConfig( const moduleDirs = packages.map((p: any) => resolvePath(p.location, 'node_modules'), ); - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); // See frontend config + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); // See frontend config const { loaders } = transforms(options); diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 390e41a345..f38416ca4e 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -15,8 +15,25 @@ */ import fs from 'fs-extra'; +import path from 'path'; import { paths } from '../paths'; +/** + * Checks if dir is the same as or a child of base. + */ +export function isChildPath(base: string, dir: string): boolean { + const relativePath = path.relative(base, dir); + if (relativePath === '') { + // The same directory + return true; + } + + const outsideBase = relativePath.startsWith('..'); // not outside base + const differentDrive = path.isAbsolute(relativePath); // on Windows, this means dir is on a different drive from base. + + return !outsideBase && !differentDrive; +} + export type BundlingPathsOptions = { // bundle entrypoint, e.g. 'src/index' entry: string; diff --git a/packages/cli/src/lib/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/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 30891c2dd9..a5e34ad91e 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/config-loader +## 0.4.1 + +### Patch Changes + +- ad5c56fd9: Deprecate `$data` and replace it with `$include` which allows for any type of json value to be read from external files. In addition, `$include` can be used without a path, which causes the value at the root of the file to be loaded. + + Most usages of `$data` can be directly replaced with `$include`, except if the referenced value is not a string, in which case the value needs to be changed. For example: + + ```yaml + # app-config.yaml + foo: + $data: foo.yaml#myValue # replacing with $include will turn the value into a number + $data: bar.yaml#myValue # replacing with $include is safe + + # foo.yaml + myValue: 0xf00 + + # bar.yaml + myValue: bar + ``` + ## 0.4.0 ### Minor Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 3e2c58c676..476c57b014 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.4.0", + "version": "0.4.1", "private": false, "publishConfig": { "access": "public", @@ -36,7 +36,7 @@ "fs-extra": "^9.0.0", "json-schema": "^0.2.5", "json-schema-merge-allof": "^0.7.0", - "typescript-json-schema": "^0.45.0", + "typescript-json-schema": "^0.47.0", "yaml": "^1.9.2", "yup": "^0.29.3" }, diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts index d80ada193b..189834f3aa 100644 --- a/packages/config-loader/src/lib/secrets.test.ts +++ b/packages/config-loader/src/lib/secrets.test.ts @@ -28,6 +28,7 @@ const ctx: ReaderContext = { 'my-data.json': '{"a":{"b":{"c":42}}}', 'my-data.yaml': 'some:\n yaml:\n key: 7', 'my-data.yml': 'different: { key: hello }', + 'invalid.yaml': 'foo: [}', } as { [key: string]: string })[path]; if (!content) { @@ -84,6 +85,41 @@ describe('readSecret', () => { ).rejects.toThrow('File not found!'); }); + it('should include extra files', async () => { + // New format with path in fragment + await expect( + readSecret({ include: 'my-data.json#a.b.c' }, ctx), + ).resolves.toBe(42); + await expect( + readSecret({ include: 'my-data.json#a.b' }, ctx), + ).resolves.toEqual({ c: 42 }); + await expect( + readSecret({ include: 'my-data.yaml#some.yaml.key' }, ctx), + ).resolves.toBe(7); + await expect(readSecret({ include: 'my-data.yaml' }, ctx)).resolves.toEqual( + { + some: { yaml: { key: 7 } }, + }, + ); + await expect( + readSecret({ include: 'my-data.yaml#' }, ctx), + ).resolves.toEqual({ + some: { yaml: { key: 7 } }, + }); + await expect( + readSecret({ include: 'my-data.yml#different.key' }, ctx), + ).resolves.toBe('hello'); + await expect( + readSecret({ include: 'no-data.yml#different.key' }, ctx), + ).rejects.toThrow('File not found!'); + await expect( + readSecret({ include: 'my-data.yml#missing.key' }, ctx), + ).rejects.toThrow('Value is not an object at missing in my-data.yml'); + await expect(readSecret({ include: 'invalid.yaml' }, ctx)).rejects.toThrow( + 'Failed to parse included file invalid.yaml, YAMLSyntaxError: Flow sequence contains an unexpected }', + ); + }); + it('should reject invalid secrets', async () => { await expect(readSecret('hello' as any, ctx)).rejects.toThrow( 'secret must be a `object` type, but the final value was: `"hello"`.', diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts index 97f0de2940..b0e0a0dd7b 100644 --- a/packages/config-loader/src/lib/secrets.ts +++ b/packages/config-loader/src/lib/secrets.ts @@ -42,7 +42,12 @@ type DataSecret = { path?: string; }; -type Secret = FileSecret | EnvSecret | DataSecret; +// TODO(Rugvip): Move this out of secret reading when we remove the deprecated DataSecret and $secret format +type IncludeSecret = { + include: string; +}; + +type Secret = FileSecret | EnvSecret | DataSecret | IncludeSecret; // Schema for each type of secret description const secretLoaderSchemas = { @@ -55,6 +60,9 @@ const secretLoaderSchemas = { data: yup.object({ data: yup.string().required(), }), + include: yup.object({ + include: yup.string().required(), + }), }; // The top-level secret schema, which figures out what type of secret it is. @@ -94,7 +102,7 @@ const dataSecretParser: { export async function readSecret( data: JsonObject, ctx: ReaderContext, -): Promise { +): Promise { const secret = secretSchema.validateSync(data, { strict: true }) as Secret; if ('file' in secret) { @@ -104,6 +112,9 @@ export async function readSecret( return ctx.env[secret.env]; } if ('data' in secret) { + console.warn( + `Configuration uses deprecated $data key, use $include instead.`, + ); const url = 'path' in secret ? `${secret.data}#${secret.path}` : secret.data; const [filePath, dataPath] = url.split(/#(.*)/); @@ -134,6 +145,35 @@ export async function readSecret( return String(value); } + if ('include' in secret) { + const [filePath, dataPath] = secret.include.split(/#(.*)/); + + const ext = extname(filePath); + const parser = dataSecretParser[ext]; + if (!parser) { + throw new Error(`No data secret parser available for extension ${ext}`); + } + + const content = await ctx.readFile(filePath); + + const parts = dataPath ? dataPath.split('.') : []; + + let value: JsonValue | undefined; + try { + value = await parser(content); + } catch (error) { + throw new Error(`Failed to parse included file ${filePath}, ${error}`); + } + for (const [index, part] of parts.entries()) { + if (!isObject(value)) { + const errPath = parts.slice(0, index).join('.'); + throw new Error(`Value is not an object at ${errPath} in ${filePath}`); + } + value = value[part]; + } + + return value; + } isNever(); throw new Error('Secret was left unhandled'); diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts index e189aef20d..95e1d6655e 100644 --- a/packages/config-loader/src/lib/types.ts +++ b/packages/config-loader/src/lib/types.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { JsonObject } from '@backstage/config'; +import { JsonObject, JsonValue } from '@backstage/config'; export type ReadFileFunc = (path: string) => Promise; export type ReadSecretFunc = ( path: string, desc: JsonObject, -) => Promise; +) => Promise; export type SkipFunc = (path: string) => boolean; /** diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index a647367469..11cb1dc338 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath, dirname, isAbsolute } from 'path'; -import { AppConfig, JsonObject } from '@backstage/config'; +import { AppConfig, JsonObject, JsonValue } from '@backstage/config'; import { readConfigFile, readEnvConfig, readSecret } from './lib'; export type LoadConfigOptions = { @@ -49,7 +49,7 @@ class Context { async readSecret( _path: string, desc: JsonObject, - ): Promise { + ): Promise { return readSecret(desc, this); } } diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index de997bf065..a40e25b2a8 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,28 @@ # @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 diff --git a/packages/core-api/package.json b/packages/core-api/package.json index fa8bd7a086..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.6", + "version": "0.2.9", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,6 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.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/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 f89634e0ec..4c2da34098 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -25,25 +25,15 @@ export class AbsoluteRouteRef { // 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; } - /** - * This function should not be used, create a separate RouteRef instead - * @deprecated - */ - createSubRoute(): any { - throw new Error( - 'This method should not be called, create a separate RouteRef instead', - ); - } - toString() { - return `routeRef{path=${this.path}}`; + return `routeRef{title=${this.title}}`; } } @@ -54,13 +44,7 @@ export function createRouteRef< return new AbsoluteRouteRef(config); } -const create = Symbol('create-external-route-ref'); - export class ExternalRouteRef { - static [create]() { - return new ExternalRouteRef(); - } - private constructor() {} toString() { @@ -69,5 +53,5 @@ export class ExternalRouteRef { } export function createExternalRouteRef(): ExternalRouteRef { - return ExternalRouteRef[create](); + return new ((ExternalRouteRef as unknown) as { new (): ExternalRouteRef })(); } diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index 200846f175..44d53856b4 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -15,7 +15,11 @@ */ import React, { PropsWithChildren } from 'react'; -import { routePathCollector, routeParentCollector } from './collectors'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; import { traverseElementTree, @@ -24,21 +28,22 @@ import { } from '../extensions/traversal'; import { createRouteRef } from './RouteRef'; import { createPlugin } from '../plugin'; -import { createRoutableExtension } from '../extensions'; +import { attachComponentData, createRoutableExtension } from '../extensions'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RouteRef } from './types'; -const mockConfig = () => ({ path: '/foo', title: 'Foo' }); const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( <>{children} ); const plugin = createPlugin({ id: 'my-plugin' }); -const ref1 = createRouteRef(mockConfig()); -const ref2 = createRouteRef(mockConfig()); -const ref3 = createRouteRef(mockConfig()); -const ref4 = createRouteRef(mockConfig()); -const ref5 = createRouteRef(mockConfig()); +const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); +const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); +const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); +const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; const Extension1 = plugin.provide( createRoutableExtension({ @@ -71,6 +76,30 @@ const Extension5 = plugin.provide( }), ); +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 = [ @@ -109,33 +138,40 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routes, routeParents, routeObjects } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { routes: routePathCollector, routeParents: routeParentCollector, + routeObjects: routeObjectCollector, }, }); - expect(routes).toEqual( - new Map([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]), - ); - - expect(routeParents).toEqual( - new Map([ - [ref1, undefined], - [ref2, ref1], - [ref3, ref2], - [ref4, undefined], - [ref5, ref1], - ]), - ); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, undefined], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj('/bar/:id', [ref2], [routeObj('/baz', [ref3])]), + routeObj('/blop', [ref5]), + ], + ), + routeObj('/divsoup', [ref4]), + ]); }); it('should handle all react router Route patterns', () => { @@ -168,24 +204,154 @@ describe('discovery', () => { routeParents: routeParentCollector, }, }); - expect(routes).toEqual( - new Map([ - [ref1, '/foo'], - [ref2, '/bar/:id'], - [ref3, '/baz'], - [ref4, '/divsoup'], - [ref5, '/blop'], - ]), + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const root = ( + + + + +
+ +
+ HELLO +
+ + + + + + + +
+
); - expect(routeParents).toEqual( - new Map([ - [ref1, undefined], - [ref2, ref1], - [ref3, undefined], - [ref4, ref3], - [ref5, ref3], - ]), + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/foo'], + [ref3, '/bar'], + [ref4, '/baz'], + [ref5, '/baz'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(routeObjects).toEqual([ + routeObj('/foo', [ref1, ref2]), + routeObj('/bar', [ref3], [routeObj('/baz', [ref4, ref5])]), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const root = ( + + + + + + + + + + + + + + ); + + const { routes, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + expect(sortedEntries(routes)).toEqual([ + [ref1, '/foo'], + [ref2, '/bar'], + [ref3, '/baz'], + [ref4, '/blop'], + [ref5, '/bar'], + ]); + expect(sortedEntries(routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref1], + [ref4, ref3], + [ref5, ref1], + ]); + expect(routeObjects).toEqual([ + routeObj( + '/foo', + [ref1], + [ + routeObj( + '/bar', + [ref2, ref5], + [routeObj('/baz', [ref3], [routeObj('/blop', [ref4])])], + ), + ], + ), + ]); + }); + + it('should stop gathering mount points after encountering explicit path', () => { + const root = ( + + + + + + + + + + + + ); + + expect(() => { + traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routePathCollector, + routeParents: routeParentCollector, + }, + }); + }).toThrow('Mounted routable extension must have a path'); }); it('should not visit the same element twice', () => { diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index e47a59aa85..37362cf8d4 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -32,25 +32,48 @@ function getMountPoint(node: ReactElement): RouteRef | undefined { export const routePathCollector = createCollector( () => new Map(), - (acc, node, parent) => { + (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; + 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) { - const path: string | undefined = node.props?.path; + 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) => { + (acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => { if (parent?.props.element === node) { return parentRouteRef; } @@ -59,8 +82,27 @@ export const routeParentCollector = createCollector( const routeRef = getMountPoint(node); if (routeRef) { - acc.set(routeRef, parentRouteRef); - nextParent = 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; @@ -69,9 +111,10 @@ export const routeParentCollector = createCollector( export const routeObjectCollector = createCollector( () => Array(), - (acc, node, parent, parentChildArr: BackstageRouteObject[] = acc) => { + (acc, node, parent, parentObj: BackstageRouteObject | undefined) => { + const parentChildren = parentObj?.children ?? acc; if (parent?.props.element === node) { - return parentChildArr; + return parentObj; } const path: string | undefined = node.props?.path; @@ -79,20 +122,40 @@ export const routeObjectCollector = createCollector( const routeRef = getMountPoint(node); if (routeRef) { - const children: BackstageRouteObject[] = []; - if (!path) { - throw new Error(`No path found for mount point ${routeRef}`); + if (path) { + const newObject: BackstageRouteObject = { + caseSensitive, + path, + element: null, + routeRefs: new Set([routeRef]), + children: [], + }; + parentChildren.push(newObject); + return newObject; } - parentChildArr.push({ + + 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, - routeRef, - children, - }); - return children; + routeRefs: new Set(), + children: [], + }; + parentChildren.push(newObject); + return newObject; } - return parentChildArr; + return parentObj; }, ); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 55fde89de2..3a18d8b0af 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -60,8 +60,8 @@ class RouteResolver { currentRouteRef; currentRouteRef = this.routeParents.get(currentRouteRef) ) { - matchIndex = match.findIndex( - m => (m.route as BackstageRouteObject).routeRef === currentRouteRef, + matchIndex = match.findIndex(m => + (m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!), ); if (matchIndex !== -1) { break; diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 3591f299f1..9564b3b225 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -21,5 +21,6 @@ export type { 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 e91ca7ea53..99a20c31e9 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -19,14 +19,10 @@ import { IconComponent } from '../icons'; // @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; - /** - * This function should not be used, create a separate RouteRef instead - * @deprecated - */ - createSubRoute(): any; }; export type AnyRouteRef = RouteRef; @@ -51,7 +47,8 @@ export type MutableRouteRef = RouteRef<{}>; export type RouteRefConfig = { params?: Array; - path: string; + /** @deprecated Route refs no longer decide their own path */ + path?: string; icon?: IconComponent; title: string; }; @@ -62,5 +59,5 @@ export interface BackstageRouteObject { children?: BackstageRouteObject[]; element: React.ReactNode; path: string; - routeRef: AnyRouteRef; + routeRefs: Set; } diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d4eb0a0a95..bb13447f94 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/core +## 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 diff --git a/packages/core/package.json b/packages/core/package.json index 3c636a0d80..b84eb4ff7b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.4.1", + "version": "0.4.3", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/core-api": "^0.2.6", + "@backstage/core-api": "^0.2.8", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -65,8 +65,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.4", + "@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/pagerduty/src/components/About/SubHeader.tsx b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx similarity index 73% rename from plugins/pagerduty/src/components/About/SubHeader.tsx rename to packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx index 5e4ea96fe0..a2f86d133b 100644 --- a/plugins/pagerduty/src/components/About/SubHeader.tsx +++ b/packages/core/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { VerticalIcon } from './VerticalIcon'; -import { SubHeaderLink } from '../types'; +import { IconLinkVertical, IconLinkVerticalProps } from './IconLinkVertical'; import { makeStyles } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ @@ -29,21 +28,15 @@ const useStyles = makeStyles(theme => ({ })); type Props = { - links: SubHeaderLink[]; + links: IconLinkVerticalProps[]; }; -export const SubHeader = ({ links }: Props) => { +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/plugins/pagerduty/src/components/About/index.ts b/packages/core/src/components/HeaderIconLinkRow/index.ts similarity index 81% rename from plugins/pagerduty/src/components/About/index.ts rename to packages/core/src/components/HeaderIconLinkRow/index.ts index 4e495daa54..82fb27cfad 100644 --- a/plugins/pagerduty/src/components/About/index.ts +++ b/packages/core/src/components/HeaderIconLinkRow/index.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -export { AboutCard } from './AboutCard'; -// export { SubHeader } from './SubHeader'; -// export { VerticalIcon } from './VerticalIcon'; +export { HeaderIconLinkRow } from './HeaderIconLinkRow'; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index a8ae0213c4..85ebba7a58 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -38,3 +38,4 @@ export * from './TrendLine'; export * from './WarningPanel'; export * from './EmptyState'; export * from './MarkdownContent'; +export * from './HeaderIconLinkRow'; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 8e9b3692af..235660acc2 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,101 @@ # @backstage/create-app +## 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 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 52691fc2ee..70ce66de11 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.5", + "version": "0.3.2", "private": false, "publishConfig": { "access": "public" @@ -37,39 +37,43 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.5", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.1", - "@backstage/plugin-api-docs": "^0.4.1", + "@backstage/core": "^0.4.3", + "@backstage/plugin-api-docs": "^0.4.2", "@backstage/plugin-app-backend": "^0.3.3", - "@backstage/plugin-auth-backend": "^0.2.7", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.0", + "@backstage/plugin-auth-backend": "^0.2.9", + "@backstage/plugin-catalog": "^0.2.10", + "@backstage/plugin-catalog-backend": "^0.5.2", + "@backstage/plugin-catalog-import": "^0.3.3", "@backstage/plugin-circleci": "^0.2.5", "@backstage/plugin-explore": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.5", + "@backstage/plugin-github-actions": "^0.2.6", "@backstage/plugin-lighthouse": "^0.2.6", "@backstage/plugin-proxy-backend": "^0.2.3", - "@backstage/plugin-register-component": "^0.2.5", - "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder": "^0.3.4", - "@backstage/plugin-search": "^0.2.4", - "@backstage/plugin-scaffolder-backend": "^0.3.5", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.0", - "@backstage/plugin-techdocs-backend": "^0.4.0", + "@backstage/plugin-rollbar-backend": "^0.1.6", + "@backstage/plugin-scaffolder": "^0.3.6", + "@backstage/plugin-search": "^0.2.5", + "@backstage/plugin-scaffolder-backend": "^0.4.0", + "@backstage/plugin-tech-radar": "^0.3.2", + "@backstage/plugin-techdocs": "^0.5.2", + "@backstage/plugin-techdocs-backend": "^0.5.2", "@backstage/plugin-user-settings": "^0.2.3", - "@backstage/test-utils": "^0.1.5", + "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "ts-node": "^8.6.2" }, + "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 df8b9c01ea..5cbf6c77ee 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -39,12 +39,12 @@ import { version as pluginAppBackend } from '@backstage/plugin-app-backend/packa import { version as pluginAuthBackend } from '@backstage/plugin-auth-backend/package.json'; import { version as pluginCatalog } from '@backstage/plugin-catalog/package.json'; import { version as pluginCatalogBackend } from '@backstage/plugin-catalog-backend/package.json'; +import { version as pluginCatalogImport } from '@backstage/plugin-catalog-import/package.json'; import { version as pluginCircleci } from '@backstage/plugin-circleci/package.json'; import { version as pluginExplore } from '@backstage/plugin-explore/package.json'; import { version as pluginGithubActions } from '@backstage/plugin-github-actions/package.json'; import { version as pluginLighthouse } from '@backstage/plugin-lighthouse/package.json'; import { version as pluginProxyBackend } from '@backstage/plugin-proxy-backend/package.json'; -import { version as 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'; @@ -67,12 +67,12 @@ 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, diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 7b70a2192c..90f272270a 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -65,9 +65,6 @@ techdocs: publisher: type: 'local' -lighthouse: - baseUrl: http://localhost:3003 - auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers providers: {} diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 975a5a2ed2..1353e33072 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", 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 1746f171c9..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,12 +10,11 @@ "@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'}}", @@ -46,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", 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 2e250a94a1..693e66e0c6 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 @@ -12,7 +12,7 @@ import { AppSidebar } from './sidebar'; import { Route, Routes, Navigate } from 'react-router'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { SearchPage as SearchRouter } from '@backstage/plugin-search'; @@ -52,8 +52,8 @@ const App = () => ( element={} /> } + path="/catalog-import" + element={} /> ( - - {/* End global nav */} diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 2c8f460a35..c62d7a5e02 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 @@ -37,7 +37,7 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", {{/if}} "winston": "^3.2.1" }, 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 1bbb5ff24b..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,10 +1,7 @@ import { createRouter, - DirectoryPreparer, Preparers, Generators, - TechdocsGenerator, - CommonGitPreparer, Publisher, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; @@ -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 = Publisher.fromConfig(config, 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 e634a09c20..1938754169 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/dev-utils +## 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 diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index d52e14a728..785607c6d3 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.6", + "version": "0.1.7", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.0", + "@backstage/core": "^0.4.3", + "@backstage/catalog-model": "^0.6.0", + "@backstage/plugin-catalog": "^0.2.9", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -45,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.4.4", "@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/packages/dev-utils/src/components/index.ts b/packages/dev-utils/src/components/index.ts new file mode 100644 index 0000000000..34224f09fd --- /dev/null +++ b/packages/dev-utils/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './EntityGridItem'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 86a8b2f7cb..f1a6d30b4c 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { hot } from 'react-hot-loader/root'; +import { hot } from 'react-hot-loader'; import React, { ComponentType, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import BookmarkIcon from '@material-ui/icons/Bookmark'; @@ -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 @@ -75,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 */ @@ -100,7 +133,10 @@ class DevAppBuilder { {sidebar} - {deprecatedAppRoutes} + + {this.routes} + {deprecatedAppRoutes} + @@ -114,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); @@ -166,6 +207,7 @@ class DevAppBuilder { return ( + {this.sidebarItems} {sidebarItems} ); @@ -199,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/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 5559c5230d..10b30a11ad 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/integration +## 0.1.5 + +### Patch Changes + +- 036a84373: Provide support for on-prem azure devops + ## 0.1.4 ### Patch Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index fe5bd60905..258c5c61c6 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "git-url-parse": "^11.4.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.5", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", "msw": "^0.21.2" diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts index 17041a9eb2..438e776eef 100644 --- a/packages/integration/src/azure/core.test.ts +++ b/packages/integration/src/azure/core.test.ts @@ -54,6 +54,18 @@ describe('azure core', () => { 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); }); diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 44591f2509..7900774c79 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -43,7 +43,6 @@ export function getAzureFileFetchUrl(url: string): string { const ref = parsedUrl.searchParams.get('version')?.substr(2); if ( - parsedUrl.hostname !== 'dev.azure.com' || empty !== '' || userOrOrg === '' || project === '' || 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/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index af3da478a5..6a2ec6cf97 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,78 @@ # @backstage/techdocs-common +## 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 diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts new file mode 100644 index 0000000000..987b3d2800 --- /dev/null +++ b/packages/techdocs-common/__mocks__/aws-sdk.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { S3 as S3Types } from 'aws-sdk'; +import { EventEmitter } from 'events'; +import fs from 'fs'; + +export class S3 { + private readonly options; + + constructor(options: S3Types.ClientConfiguration) { + this.options = options; + } + + headObject({ Key }: { Key: string }) { + return { + promise: () => this.checkFileExists(Key), + }; + } + + getObject({ Key }: { Key: string }) { + return { + promise: () => this.checkFileExists(Key), + createReadStream: () => { + const emitter = new EventEmitter(); + process.nextTick(() => { + if (fs.existsSync(Key)) { + emitter.emit('data', Buffer.from(fs.readFileSync(Key))); + } else { + emitter.emit( + 'error', + new Error(`The file ${Key} doest not exist !`), + ); + } + emitter.emit('end'); + }); + return emitter; + }, + }; + } + + checkFileExists(Key: string) { + return new Promise((resolve, reject) => { + if (fs.existsSync(Key)) { + resolve(''); + } else { + reject({ message: 'The object doest not exist !' }); + } + }); + } + + headBucket() { + return new Promise(resolve => { + resolve(''); + }); + } + + upload({ Key }: { Key: string }) { + return { + promise: () => + new Promise((resolve, reject) => { + if (!fs.existsSync(Key)) { + reject(''); + } else { + resolve(''); + } + }), + }; + } +} + +export default { + S3, +}; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index d0c09919a0..ab83f9b262 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.2.0", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -36,32 +36,32 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", + "@backstage/integration": "^0.1.5", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", + "aws-sdk": "^2.817.0", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.3", - "js-yaml": "^3.14.0", + "js-yaml": "^4.0.0", "mime-types": "^2.1.27", "mock-fs": "^4.13.0", - "nodegit": "^0.27.0", "recursive-readdir": "^2.2.2", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.5", "@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/nodegit": "^0.26.12", "@types/recursive-readdir": "^2.2.0" }, "jest": { diff --git a/packages/techdocs-common/src/default-branch.ts b/packages/techdocs-common/src/default-branch.ts index 1090fcb3d3..c0ec63af10 100644 --- a/packages/techdocs-common/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 index b580a6f05b..b58afae490 100644 --- a/packages/techdocs-common/src/git-auth.ts +++ b/packages/techdocs-common/src/git-auth.ts @@ -15,7 +15,14 @@ */ import parseGitUrl from 'git-url-parse'; import { Config } from '@backstage/config'; -import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; +import { + readGitHubIntegrationConfigs, + readGitLabIntegrationConfigs, + readAzureIntegrationConfigs, + GitHubIntegrationConfig, + GitLabIntegrationConfig, + AzureIntegrationConfig, +} from '@backstage/integration'; export function getGitHost(url: string): string { const { resource } = parseGitUrl(url); @@ -34,83 +41,65 @@ export function getGitRepoType(url: string): string { return type; } -export function getGithubHostToken( +export const getGitHubIntegrationConfig = ( config: Config, host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.github') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, +): GitHubIntegrationConfig => { + const allGitHubConfigs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.github.privateToken') ?? - config.getOptionalString('catalog.processors.githubApi.privateToken') ?? - process.env.GITHUB_TOKEN; + const gitHubIntegrationConfig = allGitHubConfigs.find(v => v.host === host); + if (!gitHubIntegrationConfig) { + throw new Error(`Unable to locate GitHub integration for the host ${host}`); + } + return gitHubIntegrationConfig; +}; - return token; -} - -export function getGitlabHostToken( +export const getGitLabIntegrationConfig = ( config: Config, host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.gitlab') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, +): GitLabIntegrationConfig => { + const allGitLabConfigs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.gitlab.privateToken') ?? - config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? - process.env.GITLAB_TOKEN; + const gitLabIntegrationConfig = allGitLabConfigs.find(v => v.host === host); + if (!gitLabIntegrationConfig) { + throw new Error(`Unable to locate GitLab integration for the host ${host}`); + } + return gitLabIntegrationConfig; +}; - return token; -} - -export function getAzureHostToken( +export const getAzureIntegrationConfig = ( config: Config, host: string, -): string | undefined { - const providerConfigs = - config.getOptionalConfigArray('integrations.azure') ?? []; - - const hostConfig = providerConfigs.filter( - providerConfig => providerConfig.getOptionalString('host') === host, +): AzureIntegrationConfig => { + const allAzureIntegrationConfig = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], ); - const token = - hostConfig[0]?.getOptionalString('token') ?? - config.getOptionalString('catalog.processors.azureApi.privateToken') ?? - process.env.AZURE_TOKEN; - - return token; -} + 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 => { - // TODO(Rugvip): Config should not be loaded here, pass it in instead - const config = await loadBackendConfig({ - logger: getRootLogger(), - argv: process.argv, - }); - const host = getGitHost(repositoryUrl); const type = getGitRepoType(repositoryUrl); try { switch (type) { case 'github': - return getGithubHostToken(config, host); + return getGitHubIntegrationConfig(config, host).token; case 'gitlab': - return getGitlabHostToken(config, host); + return getGitLabIntegrationConfig(config, host).token; case 'azure/api': - return getAzureHostToken(config, host); - + return getAzureIntegrationConfig(config, host).token; default: throw new Error('Failed to get repository type'); } diff --git a/packages/techdocs-common/src/helpers.ts b/packages/techdocs-common/src/helpers.ts index e08d58d58e..f70b0f3840 100644 --- a/packages/techdocs-common/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 './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/stages/generate/generators.ts b/packages/techdocs-common/src/stages/generate/generators.ts index f28a169942..cbfa5e7553 100644 --- a/packages/techdocs-common/src/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/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index 83ccbbffab..48a48986f2 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -303,7 +303,7 @@ describe('helpers', () => { const updatedMkdocsYml = await fs.readFile('/mkdocs.yml'); expect(updatedMkdocsYml.toString()).toContain( - "repo_url: 'https://github.com/backstage/backstage'", + 'repo_url: https://github.com/backstage/backstage', ); }); @@ -322,10 +322,10 @@ describe('helpers', () => { const updatedMkdocsYml = await fs.readFile('/mkdocs_with_repo_url.yml'); expect(updatedMkdocsYml.toString()).toContain( - "repo_url: 'https://github.com/backstage/backstage'", + 'repo_url: https://github.com/backstage/backstage', ); expect(updatedMkdocsYml.toString()).not.toContain( - "repo_url: 'https://github.com/neworg/newrepo'", + 'repo_url: https://github.com/neworg/newrepo', ); }); }); diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 773b542517..16f5b26632 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -241,7 +241,7 @@ export const patchMkdocsYmlPreBuild = async ( let mkdocsYml: any; try { - mkdocsYml = yaml.safeLoad(mkdocsYmlFileString); + mkdocsYml = yaml.load(mkdocsYmlFileString); // mkdocsYml should be an object type after successful parsing. // But based on its type definition, it can also be a string or undefined, which we don't want. @@ -267,7 +267,7 @@ export const patchMkdocsYmlPreBuild = async ( } try { - await fs.writeFile(mkdocsYmlPath, yaml.safeDump(mkdocsYml), 'utf8'); + await fs.writeFile(mkdocsYmlPath, yaml.dump(mkdocsYml), 'utf8'); } catch (error) { logger.warn( `Could not write to ${mkdocsYmlPath} after updating it before running the generator. ${error.message}`, diff --git a/packages/techdocs-common/src/stages/prepare/commonGit.test.ts b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts index a4240514b5..704cd23daa 100644 --- a/packages/techdocs-common/src/stages/prepare/commonGit.test.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts @@ -15,6 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { CommonGitPreparer } from './commonGit'; import { checkoutGitRepository } from '../../helpers'; @@ -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/packages/techdocs-common/src/stages/prepare/commonGit.ts b/packages/techdocs-common/src/stages/prepare/commonGit.ts index d79373fba3..7eac07d76f 100644 --- a/packages/techdocs-common/src/stages/prepare/commonGit.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.ts @@ -14,17 +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 { 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; } @@ -35,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/packages/techdocs-common/src/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts index 5b51f1f46d..d082b1367b 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,8 +13,9 @@ * 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 { ConfigReader } from '@backstage/config'; +import { DirectoryPreparer } from './dir'; import { checkoutGitRepository } from '../../helpers'; function normalizePath(path: string) { @@ -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/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index 3485ecb5dd..537f670edc 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -15,6 +15,7 @@ */ 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 { InputError } from '@backstage/backend-common'; @@ -22,9 +23,11 @@ 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,7 +46,11 @@ export class DirectoryPreparer implements PreparerBase { 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/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 52a47957e8..4625d7b0e4 100644 --- a/packages/techdocs-common/src/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 { 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/packages/techdocs-common/src/stages/prepare/url.ts b/packages/techdocs-common/src/stages/prepare/url.ts index 3407684837..b938c6b5bc 100644 --- a/packages/techdocs-common/src/stages/prepare/url.ts +++ b/packages/techdocs-common/src/stages/prepare/url.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; +import { UrlReader } from '@backstage/backend-common'; import { PreparerBase } from './types'; import { getDocFilesFromRepository } from '../../helpers'; -import { Logger } from 'winston'; -import { UrlReader } from '@backstage/backend-common'; 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 100644 index 0000000000..a1d52be85b --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -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 mockFs from 'mock-fs'; +import path from 'path'; +import * as winston from 'winston'; +import { ConfigReader } from '@backstage/config'; +import { AwsS3Publish } from './awsS3'; +import { PublisherBase } 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 wrongPathToGeneratedDirectory = path.join( + 'wrong', + 'path', + 'to', + 'generatedDirectory', + ); + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => + expect(error).toEqual( + new Error( + `Unable to upload file(s) to AWS S3. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, + ), + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); + + describe('fetchTechDocsMetadata', () => { + it('should return tech docs metadata', async () => { + const entityNameMock = createMockEntityName(); + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'techdocs_metadata.json': 'file-content', + }, + }); + + expect(await publisher.fetchTechDocsMetadata(entityNameMock)).toBe( + 'file-content', + ); + mockFs.restore(); + }); + + it('should return an error if the techdocs_metadata.json file is not present', async () => { + const entityNameMock = createMockEntityName(); + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + await publisher + .fetchTechDocsMetadata(entityNameMock) + .catch(error => + expect(error).toEqual( + new Error( + `TechDocs metadata fetch failed, The file ${entityRootDir}/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..15ff33408a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import path from 'path'; +import express from 'express'; +import aws from 'aws-sdk'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest } from './types'; +import { ManagedUpload } from 'aws-sdk/clients/s3'; +import fs from 'fs-extra'; + +export class AwsS3Publish implements PublisherBase { + static fromConfig(config: Config, logger: Logger): PublisherBase { + let region = null; + let accessKeyId = null; + let secretAccessKey = null; + let bucketName = ''; + try { + accessKeyId = config.getString( + 'techdocs.publisher.awsS3.credentials.accessKeyId', + ); + secretAccessKey = config.getString( + 'techdocs.publisher.awsS3.credentials.secretAccessKey', + ); + region = config.getOptionalString('techdocs.publisher.awsS3.region'); + bucketName = config.getString('techdocs.publisher.awsS3.bucketName'); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'awsS3' in your app config, " + + 'credentials and bucketName are required in techdocs.publisher.awsS3 ' + + 'required to authenticate with AWS S3.', + ); + } + + const storageClient = new aws.S3({ + 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 AWS project and the bucket exists and the access key located at the path ' + + "techdocs.publisher.awsS3.credentials defined in app config has the role 'Storage Object Creator'. " + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(`from AWS client library: ${err.message}`); + } else { + logger.info( + `Successfully connected to the AWS S3 bucket ${bucketName}.`, + ); + } + }, + ); + + return new AwsS3Publish(storageClient, bucketName, logger); + } + + constructor( + private readonly storageClient: aws.S3, + private readonly bucketName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.bucketName = bucketName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the S3 bucket. + * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: S3 manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const 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, + }; + + uploadPromises.push(this.storageClient.upload(params).promise()); + } + 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}`; + + const fileStreamChunks: Array = []; + this.storageClient + .getObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/techdocs_metadata.json`, + }) + .createReadStream() + .on('error', err => { + this.logger.error(err.message); + reject(new Error(err.message)); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + const techdocsMetadataJson = Buffer.concat( + fileStreamChunks, + ).toString(); + resolve(techdocsMetadataJson); + }); + }); + } catch (e) { + 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); + + const fileStreamChunks: Array = []; + this.storageClient + .getObject({ Bucket: this.bucketName, Key: filePath }) + .createReadStream() + .on('error', err => { + this.logger.warn(err.message); + res.status(404).send(err.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + const fileContent = Buffer.concat(fileStreamChunks).toString(); + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + + res.send(fileContent); + }); + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + try { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + await this.storageClient + .headObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/index.html`, + }) + .promise(); + return Promise.resolve(true); + } catch (e) { + return Promise.resolve(false); + } + } +} diff --git a/packages/techdocs-common/src/stages/publish/helpers.test.ts b/packages/techdocs-common/src/stages/publish/helpers.test.ts index 3b01f5ac43..1fae66f3d2 100644 --- a/packages/techdocs-common/src/stages/publish/helpers.test.ts +++ b/packages/techdocs-common/src/stages/publish/helpers.test.ts @@ -14,6 +14,8 @@ * 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', () => { @@ -39,9 +41,11 @@ describe('getHeadersForFileExtension', () => { }); describe('getFileTreeRecursively', () => { + const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + beforeEach(() => { mockFs({ - '/rootDir': { + [root]: { file1: '', subDirA: { file2: '', @@ -57,9 +61,9 @@ describe('getFileTreeRecursively', () => { }); it('returns complete file tree of a path', async () => { - const fileList = await getFileTreeRecursively('/rootDir'); + const fileList = await getFileTreeRecursively(root); expect(fileList.length).toBe(2); - expect(fileList).toContain('/rootDir/file1'); - expect(fileList).toContain('/rootDir/subDirA/file2'); + expect(fileList).toContain(path.resolve(root, 'file1')); + expect(fileList).toContain(path.resolve(root, 'subDirA/file2')); }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index dbced6db29..d7cc257b89 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -21,26 +21,30 @@ 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 testDiscovery: jest.Mocked = { +const discovery: jest.Mocked = { getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), getExternalBaseUrl: jest.fn(), }; describe('Publisher', () => { - it('should create local publisher by default', () => { + it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', }, }); - const publisher = Publisher.fromConfig(mockConfig, logger, testDiscovery); + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); expect(publisher).toBeInstanceOf(LocalPublish); }); - it('should create local publisher from config', () => { + it('should create local publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', @@ -50,11 +54,14 @@ describe('Publisher', () => { }, }); - const publisher = Publisher.fromConfig(mockConfig, logger, testDiscovery); + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); expect(publisher).toBeInstanceOf(LocalPublish); }); - it('should create google gcs publisher from config', () => { + it('should create google gcs publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', @@ -69,7 +76,34 @@ describe('Publisher', () => { }, }); - const publisher = Publisher.fromConfig(mockConfig, logger, testDiscovery); + 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 index 04a9d89996..caaf8a4cec 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -20,17 +20,22 @@ 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 fromConfig( + static async fromConfig( config: Config, - logger: Logger, - discovery: PluginEndpointDiscovery, - ): PublisherBase { + { logger, discovery }: factoryOptions, + ): Promise { const publisherType = (config.getOptionalString( 'techdocs.publisher.type', ) ?? 'local') as PublisherType; @@ -39,6 +44,9 @@ export class Publisher { case 'googleGcs': logger.info('Creating Google Storage Bucket publisher for TechDocs'); return GoogleGCSPublish.fromConfig(config, logger); + case 'awsS3': + logger.info('Creating AWS S3 Bucket publisher for TechDocs'); + return AwsS3Publish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 9bfd8cb334..24a9d05adf 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,7 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs'; +export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; export type PublishRequest = { entity: Entity; diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index b311d58b94..6d23f26691 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,14 @@ # @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 diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index e550caee4b..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.5", + "version": "0.1.6", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-api": "^0.2.5", + "@backstage/core-api": "^0.2.7", "@backstage/test-utils-core": "^0.1.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.4.3", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, 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 dc1c38ef49..995dfdc175 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-api-docs +## 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 diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 7346722550..0bbd96f1a8 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.1", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.7", + "@backstage/core": "^0.4.3", + "@backstage/plugin-catalog": "^0.2.9", "@backstage/theme": "^0.2.2", "@kyma-project/asyncapi-react": "^0.14.2", "@material-icons/font": "^1.0.2", @@ -49,9 +49,9 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/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/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index f011d224b4..ce029cded2 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-auth-backend +## 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 diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index c971528e14..f7e1da9b96 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -47,6 +47,11 @@ export interface Config { saml?: { entryPoint: string; issuer: string; + cert?: string; + privateKey?: string; + decryptionPvk?: string; + signatureAlgorithm?: 'sha1' | 'sha256' | 'sha512'; + digestAlgorithm?: string; }; okta?: { development: { [key: string]: string }; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index c55d4e508f..713ad07617 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.7", + "version": "0.2.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/catalog-client": "^0.3.4", "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", @@ -58,19 +58,17 @@ "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", - "passport-saml": "^1.3.3", + "passport-saml": "^1.3.5, <1.4.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.5", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", "@types/jwt-decode": "^3.1.0", - "@types/nock": "^11.1.0", - "@types/openid-client": "^3.7.0", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 4303401ee6..8b719799b1 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -87,7 +87,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/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/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 7b663a5528..4cdee1da0f 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -16,6 +16,7 @@ import express from 'express'; import { + SamlConfig, Strategy as SamlStrategy, Profile as SamlProfile, VerifyWithoutRequest, @@ -112,27 +113,31 @@ 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'), + 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, }; diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 95fea0ef3e..bef186f8c3 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-catalog-backend +## 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 diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 54427eae36..7877f34dcf 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -17,226 +17,370 @@ import { JsonValue } from '@backstage/config'; export interface Config { - /** Configuration options for the catalog plugin */ + /** + * Configuration options for the catalog plugin. + */ catalog?: { /** - * Rules to apply to catalog entities - accepts an array of objects with below attributes + * 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<{ /** - * attr: 'allow' - accepts an array of string. e.g. [Component, API, Group, User, Template, Location] + * Allow entities of these particular kinds. + * + * E.g. ["Component", "API", "Template", "Location"] */ allow: Array; - /** - * opt-attr: 'locations' - accepts an array of key, value pair objects. - * keys: 'target', 'type' - * e.g.[{type: url, target: https://github.com}] - * - */ - locations?: Array<{ [key: string]: string }>; }>; + /** - * List of processor options and attributes - */ - processors?: { - /** - * Github processor configuration - */ - githubOrg?: { - /** - * attr: 'providers' - accepts an array of objects with below attributes - */ - providers: Array<{ - /** - * attr: 'target' - accepts url string - * e.g. https://ghe.example.net - */ - target: string; - /** - * opt-attr: 'apiBaseUrl' - accepts baseurl string - * e.g. https://ghe.example.net/api - */ - apiBaseUrl?: string; - /** - * opt-attr: 'token' - secret token to authenticate - * e.g. {"$env": "GITHUB_TOKEN"} - * @visibility secret - * */ - token?: string; - }>; - }; - /** - * LdapOrg processor configuration - */ - ldapOrg?: { - /** - * attr: 'providers' - accepts an array of objects with below attributes - */ - providers: { - /** - * attr: 'target' - accepts url string e.g. ldaps://ds.example.net - */ - target: string; - /** - * The settings to use for the a command - * opt-attr: 'bind' - accepts key value pair object - * e.g. {"dn": "uid=ldap-reader-user","secret": {"$secret": {"env": "LDAP_SECRET"}}} - */ - bind?: { - /** - * attr: 'dn' - accepts string with ldap related parameters - * e.g. "dn": "uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net" - */ - dn: string; - /** - * secret used to authenticate. - * e.g. {"$secret": {"env": "LDAP_SECRET"} - * @visibility secret - * */ - secret: Object; - }; - /** - * The settings that govern the reading and interpretation of users. - * attr: 'users' - accepts key value pair object - */ - users: { - /** - * attr: 'dn' - accepts string with user governing parameters - * e.g. "dn": "ou=people,ou=example,dc=example,dc=net" - */ - - dn: string; - /** - * attr: 'options' - accepts an object of key value pair - * keys: 'scope', 'filter', 'attributes', 'sizeLimit', 'timeLimit', 'derefAliases', 'typesOnly', 'paged' - */ - options: { [key: string]: string | boolean | number }; - /** - * opt-attr: 'set - accepts an object of key value pair - * keys: 'path', 'value' - */ - set?: { [key: string]: string | JsonValue }; - /** - * attr: 'map' - accepts an object og key value pair - * keys: 'rdn', 'name', 'description', 'displayName', 'email', 'picture', 'memberOf' - */ - map: { [key: string]: string }; - }; - /** - * The settings that govern the reading and interpretation of groups. - * attr: 'groups' - accepts key value pair object - */ - groups: { - /** - * attr: 'dn' - accepts string with user governing parameters - * e.g. "dn": "ou=people,ou=example,dc=example,dc=net" - */ - - dn: string; - /** - * attr: 'options' - accepts an object of key value pair - * keys: 'scope', 'filter', 'attributes', 'sizeLimit', 'timeLimit', 'derefAliases', 'typesOnly', 'paged' - */ - options: { [key: string]: string | boolean | number }; - /** - * opt-attr: 'set - accepts an object of key value pair - * keys: 'path', 'value' - */ - set?: { [key: string]: string | JsonValue }; - /** - * attr: 'map' - accepts an object og key value pair - * keys: 'rdn', 'name', 'description', 'displayName', 'email', 'picture', 'memberOf' - */ - map: { [key: string]: string }; - }; - }; - }; - /** - * microsoftGraphOrg processor configuration - */ - microsoftGraphOrg?: { - /** - * attr: 'providers' - accepts object with below attributes - */ - providers: { - /** - * attr: 'target' - accepts string - * e.g. https://graph.microsoft.com/v1.0 - */ - - target: string; - /** - * attr: 'authority' - accepts string - * e.g. https://login.microsoftonline.com - */ - - authority: string; - /** - * attr: 'tenantId' - accepts string - * e.g. {"$env": "MICROSOFT_GRAPH_TENANT_ID"} - * @visibility secret - */ - - tenantId: string; - /** - * attr: 'clientId' - accepts string - * e.g. {"$env": "MICROSOFT_GRAPH_CLIENT_ID"} - * @visibility secret - */ - - clientId: string; - /** - * attr: 'clientSecret' - accepts string - * e.g. {"$env": "MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN"} - * @visibility secret - */ - - clientSecret: string; - /** - * opt-attr: 'userFilter' - accepts string - * e.g. accountEnabled eq true and userType eq 'member' - */ - - userFilter?: string; - /** - * opt-attr: 'groupFilter' - accepts string - * e.g. securityEnabled eq false and mailEnabled eq true - */ - - groupFilter?: string; - }; - }; - }; - /** - * Location specific rules used in catalog entities - accepts array of objects with below attributes + * 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<{ /** - * attr: 'type' - accepts string - * e.g. url + * The type of location, e.g. "url". */ type: string; /** - * attr: 'target' - accepts string - * e.g. https://github.com/org/repo/blob/master/users.yaml + * The target URL of the location, e.g. + * "https://github.com/org/repo/blob/master/users.yaml". */ target: string; /** - * opt-attr: 'rules' - accepts an array of objects with below attributes + * Optional extra rules that apply to this particular location. + * + * These override the global rules above. */ rules?: Array<{ /** - * attr: 'allow' - accepts an array of string. - * e.g. [Component, API, Group, User, Template, Location] + * Allow entities of these particular kinds. + * + * E.g. ["Group", "User"] */ allow: Array; - /** - * opt-attr: 'locations' - accepts an array of key, value pair objects. - * keys: 'target', 'type' - * e.g.[{type: url, target: https://github.com}] - */ - locations?: Array<{ [key: string]: string }>; }>; }>; + + /** + * 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/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 dde2c2dc65..9fbc16e3d3 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.0", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,13 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", "@octokit/graphql": "^4.5.6", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", + "aws-sdk": "^2.817.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", @@ -49,7 +50,6 @@ "morgan": "^1.10.0", "p-limit": "^3.0.2", "qs": "^6.9.4", - "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -57,8 +57,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", @@ -66,6 +66,7 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.21.2", + "sqlite3": "^5.0.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 7ba6897236..76fc63c9b3 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -205,7 +205,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } } - if (options?.outputEntities) { + if (options?.outputEntities && responses.length > 0) { const writtenEntities = await this.database.entities( tx, EntityFilters.ofMatchers({ @@ -285,7 +285,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // instead and call a dedicated batch update database method toUpdate.push(request); } else { - toIgnore.push(request); + // Use the existing entity to ensure that we're able to read it back by uid if needed + toIgnore.push({ ...request, entity: oldEntity }); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts new file mode 100644 index 0000000000..eb5e634d66 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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(() => { + return { + async promise() { + return { + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + ], + NextToken: undefined, + }; + }, + }; + }); + await processor.readLocation(location, false, emit); + expect(emit).toBeCalledWith({ + type: 'entity', + location, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + 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', + lifecycle: 'unknown', + owner: 'unknown', + }, + }, + }); + }); + + it('filters out accounts not in specified location target', async () => { + const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; + listAccounts.mockImplementation(() => { + return { + async promise() { + return { + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + { + Arn: + 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', + Name: 'testaccount2', + }, + ], + NextToken: undefined, + }; + }, + }; + }); + await processor.readLocation(location, false, emit); + expect(emit).toBeCalledTimes(1); + expect(emit).toBeCalledWith({ + type: 'entity', + location, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + 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', + lifecycle: 'unknown', + 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..f019f5207b --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + ComponentEntityV1alpha1, + LocationSpec, +} from '@backstage/catalog-model'; +import AWS, { Organizations } from 'aws-sdk'; +import { Account, ListAccountsResponse } from 'aws-sdk/clients/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 AWS.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: ListAccountsResponse = await this.organizations + .listAccounts({ NextToken: nextToken }) + .promise(); + if (orgAccounts.Accounts) { + awsAccounts = awsAccounts.concat(orgAccounts.Accounts); + } + nextToken = orgAccounts.NextToken; + } + + return awsAccounts; + } + + mapAccountToComponent(account: Account): ComponentEntityV1alpha1 { + const { accountId, organizationId } = this.extractInformationFromArn( + account.Arn as string, + ); + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [ACCOUNTID_ANNOTATION]: accountId, + [ARN_ANNOTATION]: account.Arn || '', + [ORGANIZATION_ANNOTATION]: organizationId, + }, + name: this.normalizeName(account.Name || ''), + namespace: 'default', + }, + spec: { + type: 'cloud-account', + lifecycle: 'unknown', + 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: ComponentEntityV1alpha1) => { + emit(results.entity(location, entity)); + }); + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index d31f053c80..04f5ee738a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -45,11 +45,11 @@ export class UrlReaderProcessor implements CatalogProcessor { emit: CatalogProcessorEmit, ): 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; } diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 2b477f18f9..a7b00d7065 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -17,6 +17,7 @@ import * as results from './results'; export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; 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-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index a7301e43a3..f494949aab 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-catalog-import +## 0.3.3 + +### Patch Changes + +- edb7d0775: Create "url" type location when registering using the catalog-import plugin. +- Updated dependencies [99be3057c] +- Updated dependencies [49d2016a4] +- Updated dependencies [9c09a364f] +- Updated dependencies [73e75ea0a] +- Updated dependencies [036a84373] +- Updated dependencies [071711d70] + - @backstage/plugin-catalog-backend@0.5.2 + - @backstage/plugin-catalog@0.2.10 + - @backstage/integration@0.1.5 + ## 0.3.2 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 25010268f9..495552983f 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.2", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.0", - "@backstage/integration": "^0.1.4", + "@backstage/core": "^0.4.3", + "@backstage/plugin-catalog": "^0.2.10", + "@backstage/plugin-catalog-backend": "^0.5.2", + "@backstage/integration": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,9 +50,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 0e3ea2e1d6..0e28042db3 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -49,7 +49,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - location: { type: 'github', target: repo }, + location: { type: 'url', target: repo }, }), }, ).catch(e => { @@ -78,7 +78,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - type: 'github', + type: 'url', target: location, presence: 'optional', }), diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 73f38582ff..57a8a9d205 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-catalog +## 0.2.10 + +### Patch Changes + +- 9c09a364f: Remove the unused dependency to `@backstage/plugin-techdocs`. +- Updated dependencies [8e083f41f] +- Updated dependencies [947d3c269] + - @backstage/plugin-scaffolder@0.3.6 + +## 0.2.9 + +### Patch Changes + +- 7e0b8cac5: Add `CatalogIndexPage` and `CatalogEntityPage`, two new extensions that replace the existing `Router` component. + + Add `EntityLayout` to replace `EntityPageLayout`, using children instead of an element property, and allowing for collection of all `RouteRef` mount points used within tabs. + + Add `EntitySwitch` to be used to select components based on entity data, along with accompanying `isKind`, `isNamespace`, and `isComponentType` filters. + +- 87c0c53c2: Add new `EntityProvider` component, which can be used to provide an entity for the `useEntity` hook. +- Updated dependencies [a08c32ced] +- Updated dependencies [359f9d2d8] + - @backstage/core@0.4.3 + - @backstage/plugin-techdocs@0.5.2 + +## 0.2.8 + +### Patch Changes + +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [19554f6d6] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/plugin-scaffolder@0.3.5 + - @backstage/core@0.4.2 + - @backstage/plugin-techdocs@0.5.1 + ## 0.2.7 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 142ef4a1e6..a5fa16ed6f 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.7", + "version": "0.2.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,8 @@ "dependencies": { "@backstage/catalog-client": "^0.3.4", "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", - "@backstage/plugin-scaffolder": "^0.3.4", - "@backstage/plugin-techdocs": "^0.5.0", + "@backstage/core": "^0.4.3", + "@backstage/plugin-scaffolder": "^0.3.6", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -52,9 +51,9 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.7", + "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 49653fe981..1549d87b8a 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -17,56 +17,27 @@ import { Entity, ENTITY_DEFAULT_NAMESPACE, - RELATION_OWNED_BY, RELATION_PROVIDES_API, - serializeEntityRef, } from '@backstage/catalog-model'; import { Card, CardContent, CardHeader, - Chip, Divider, - Grid, IconButton, makeStyles, - Typography, } from '@material-ui/core'; import ExtensionIcon from '@material-ui/icons/Extension'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import GitHubIcon from '@material-ui/icons/GitHub'; import React from 'react'; -import { IconLinkVertical } from './IconLinkVertical'; import { findLocationForEntityMeta } from '../../data/utils'; import { createEditLink, determineUrlType } from '../createEditLink'; +import { HeaderIconLinkRow } from '@backstage/core'; +import { AboutContent } from './AboutContent'; -const useStyles = makeStyles(theme => ({ - links: { - margin: theme.spacing(2, 0), - display: 'grid', - gridAutoFlow: 'column', - gridAutoColumns: 'min-content', - gridGap: theme.spacing(3), - }, - label: { - color: theme.palette.text.secondary, - textTransform: 'uppercase', - fontSize: '10px', - fontWeight: 'bold', - letterSpacing: 0.5, - overflow: 'hidden', - whiteSpace: 'nowrap', - }, - value: { - fontWeight: 'bold', - overflow: 'hidden', - lineHeight: '24px', - wordBreak: 'break-word', - }, - description: { - wordBreak: 'break-word', - }, +const useStyles = makeStyles({ gridItemCard: { display: 'flex', flexDirection: 'column', @@ -76,7 +47,7 @@ const useStyles = makeStyles(theme => ({ gridItemCardContent: { flex: 1, }, -})); +}); const iconMap: Record = { github: , @@ -114,6 +85,25 @@ export function AboutCard({ entity, variant }: AboutCardProps) { const codeLink = getCodeLinkInfo(entity); // TODO: Also support RELATION_CONSUMES_API here const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API); + const viewInSource = { + label: 'View Source', + ...codeLink, + }; + const viewInTechDocs = { + label: 'View TechDocs', + disabled: !entity.metadata.annotations?.['backstage.io/techdocs-ref'], + icon: , + href: `/docs/${entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${ + entity.kind + }/${entity.metadata.name}`, + }; + const viewApi = { + title: hasApis ? '' : 'No APIs available', + label: 'View API', + disabled: !hasApis, + icon: , + href: 'api', + }; return ( @@ -131,117 +121,15 @@ export function AboutCard({ entity, variant }: AboutCardProps) { } subheader={ - + } /> - - - - {entity?.metadata?.description || 'No description'} - - - r.type === RELATION_OWNED_BY) - .map(({ target: { kind, name, namespace } }) => - // TODO(Rugvip): we want to provide some utils for this - serializeEntityRef({ - kind, - name, - namespace: - namespace === ENTITY_DEFAULT_NAMESPACE - ? undefined - : namespace, - }), - ) - .join(', ')} - gridSizes={{ xs: 12, sm: 6, lg: 4 }} - /> - - - - {(entity?.metadata?.tags || []).map(t => ( - - ))} - - + ); } - -function AboutField({ - label, - value, - gridSizes, - children, -}: { - label: string; - value?: string; - gridSizes?: Record; - children?: React.ReactNode; -}) { - const classes = useStyles(); - - // Content is either children or a string prop `value` - const content = React.Children.count(children) ? ( - children - ) : ( - - {value || `unknown`} - - ); - return ( - - - {label} - - {content} - - ); -} diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx new file mode 100644 index 0000000000..b8d5d84316 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid, Typography, Chip, makeStyles } from '@material-ui/core'; +import { AboutField } from './AboutField'; +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + RELATION_OWNED_BY, + serializeEntityRef, +} from '@backstage/catalog-model'; + +const useStyles = makeStyles({ + description: { + wordBreak: 'break-word', + }, +}); + +type Props = { + entity: Entity; +}; + +export const AboutContent = ({ entity }: Props) => { + const classes = useStyles(); + return ( + + + + {entity?.metadata?.description || 'No description'} + + + r.type === RELATION_OWNED_BY) + .map(({ target: { kind, name, namespace } }) => + // TODO(Rugvip): we want to provide some utils for this + serializeEntityRef({ + kind, + name, + namespace: + namespace === ENTITY_DEFAULT_NAMESPACE ? undefined : namespace, + }), + ) + .join(', ')} + gridSizes={{ xs: 12, sm: 6, lg: 4 }} + /> + + + + {(entity?.metadata?.tags || []).map(t => ( + + ))} + + + ); +}; diff --git a/plugins/catalog/src/components/AboutCard/AboutField.tsx b/plugins/catalog/src/components/AboutCard/AboutField.tsx new file mode 100644 index 0000000000..fdcb3ace1b --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutField.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles, Typography, Grid } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + value: { + fontWeight: 'bold', + overflow: 'hidden', + lineHeight: '24px', + wordBreak: 'break-word', + }, + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '10px', + fontWeight: 'bold', + letterSpacing: 0.5, + overflow: 'hidden', + whiteSpace: 'nowrap', + }, +})); + +type Props = { + label: string; + value?: string; + gridSizes?: Record; + children?: React.ReactNode; +}; + +export const AboutField = ({ label, value, gridSizes, children }: Props) => { + const classes = useStyles(); + + // Content is either children or a string prop `value` + const content = React.Children.count(children) ? ( + children + ) : ( + + {value || `unknown`} + + ); + return ( + + + {label} + + {content} + + ); +}; diff --git a/plugins/catalog/src/components/AboutCard/index.ts b/plugins/catalog/src/components/AboutCard/index.ts index 0491d467f8..93765ff942 100644 --- a/plugins/catalog/src/components/AboutCard/index.ts +++ b/plugins/catalog/src/components/AboutCard/index.ts @@ -15,4 +15,3 @@ */ export { AboutCard } from './AboutCard'; -export { IconLinkVertical } from './IconLinkVertical'; diff --git a/plugins/pagerduty/src/components/About/AboutCard.tsx b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx similarity index 57% rename from plugins/pagerduty/src/components/About/AboutCard.tsx rename to plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx index 7d5419908a..bf5fb1d9d6 100644 --- a/plugins/pagerduty/src/components/About/AboutCard.tsx +++ b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx @@ -13,21 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; -import { Card, CardContent, CardHeader, Divider } from '@material-ui/core'; -import { SubHeader } from './SubHeader'; -import { SubHeaderLink } from '../types'; +import { Outlet } from 'react-router'; +import { EntityLoaderProvider } from '../EntityLoaderProvider'; -type Props = { - title: string; - links: SubHeaderLink[]; - content: React.ReactNode; +export const CatalogEntityPage = () => { + return ( + + + + ); }; - -export const AboutCard = ({ title, links, content }: Props) => ( - - } /> - - {content} - -); diff --git a/plugins/catalog/src/components/CatalogEntityPage/index.ts b/plugins/catalog/src/components/CatalogEntityPage/index.ts new file mode 100644 index 0000000000..627ca80e48 --- /dev/null +++ b/plugins/catalog/src/components/CatalogEntityPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { CatalogEntityPage } from './CatalogEntityPage'; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx new file mode 100644 index 0000000000..5d1f010740 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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 { EntityLayout } from './EntityLayout'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Routes, Route } from 'react-router'; +import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '../../hooks/useEntity'; +import { catalogApiRef } from '../../plugin'; +import { CatalogApi } from '@backstage/catalog-client'; + +const mockEntityData = { + loading: false, + error: undefined, + entity: { + kind: 'MyKind', + metadata: { + name: 'my-entity', + }, + } as Entity, +}; + +const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi).with( + alertApiRef, + {} as AlertApi, +); + +describe('EntityLayout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + + + + +
tabbed-test-content
+
+
+
+
, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of TabbedLayout', async () => { + const { error } = await withLogCollector(async () => { + await expect( + renderInTestApp( + + +
tabbed-test-content
+
+
This will cause app to throw
+
, + ), + ).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/); + }); + + expect(error).toEqual([ + expect.stringMatching( + /Child of EntityLayout must be an EntityLayout.Route/, + ), + expect.stringMatching( + /The above error occurred in the component/, + ), + ]); + }); + + it('navigates when user clicks different tab', async () => { + const rendered = await renderInTestApp( + + + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+
+ + } + /> +
, + ); + + const secondTab = rendered.queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + 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(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx new file mode 100644 index 0000000000..09b8d02c60 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -0,0 +1,192 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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, + Fragment, + PropsWithChildren, + ReactNode, + isValidElement, + useContext, + useState, +} from 'react'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { + attachComponentData, + Content, + Header, + HeaderLabel, + Page, + Progress, +} from '@backstage/core'; +import { Box } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { useNavigate } from 'react-router'; +import { EntityContext } from '../../hooks/useEntity'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { useEntityCompoundName } from '../useEntityCompoundName'; +import { TabbedLayout } from './TabbedLayout'; + +type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +export function createSubRoutesFromChildren(children: ReactNode): SubRoute[] { + return Children.toArray(children).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children); + } + + if (child.type !== Route) { + throw new Error('Child of EntityLayout must be an EntityLayout.Route'); + } + + const { path, title, children } = child.props; + return [{ path, title, children }]; + }); +} + +const EntityLayoutTitle = ({ + entity, + title, +}: { + title: string; + entity: Entity | undefined; +}) => ( + + {title} + {entity && } + +); + +const headerProps = ( + paramKind: string | undefined, + paramNamespace: string | undefined, + paramName: string | undefined, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } => { + const kind = paramKind ?? entity?.kind ?? ''; + const namespace = paramNamespace ?? entity?.metadata.namespace ?? ''; + const name = paramName ?? entity?.metadata.name ?? ''; + return { + headerTitle: `${name}${ + namespace && namespace !== ENTITY_DEFAULT_NAMESPACE + ? ` in ${namespace}` + : '' + }`, + headerType: (() => { + let t = kind.toLowerCase(); + if (entity && entity.spec && 'type' in entity.spec) { + t += ' — '; + t += (entity.spec as { type: string }).type.toLowerCase(); + } + return t; + })(), + }; +}; + +/** + * EntityLayout is a compound component, which allows you to define a layout for + * entities using a sub-navigation mechanism. + * + * Consists of two parts: EntityLayout and EntityLayout.Route + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { + const { kind, namespace, name } = useEntityCompoundName(); + const { entity, loading, error } = useContext(EntityContext); + + const routes = createSubRoutesFromChildren(children); + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity, + ); + + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const navigate = useNavigate(); + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate('/'); + }; + + const showRemovalDialog = () => setConfirmationDialogOpen(true); + + return ( + +
} + pageTitleOverride={headerTitle} + type={headerType} + > + {/* TODO: fix after catalog page customization is added */} + {entity && kind !== 'user' && ( + <> + + + + + )} +
+ + {loading && } + + {entity && } + + {error && ( + + {error.toString()} + + )} + setConfirmationDialogOpen(false)} + /> +
+ ); +}; + +EntityLayout.Route = Route; diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx new file mode 100644 index 0000000000..065265cea4 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx @@ -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 React from 'react'; +import { TabbedLayout } from './TabbedLayout'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Routes, Route } from 'react-router'; + +const testRoute1 = { + path: '', + title: 'tabbed-test-title', + children:
tabbed-test-content
, +}; +const testRoute2 = { + title: 'tabbed-test-title-2', + path: '/some-other-path', + children:
tabbed-test-content-2
, +}; + +describe('TabbedLayout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + , + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('navigates when user clicks different tab', async () => { + const rendered = await renderInTestApp( + + } + /> + , + ); + + const secondTab = rendered.queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + 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(); + }); + + describe('correctly delegates nested links', () => { + const renderRoute = (route: string) => + renderInTestApp( + + + tabbed-test-content-2 + + tabbed-test-nested-content-2} + /> + + + ), + }, + ]} + /> + } + /> + , + { 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/catalog/src/components/EntityLayout/index.ts b/plugins/catalog/src/components/EntityLayout/index.ts new file mode 100644 index 0000000000..2e399765c3 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/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 { EntityLayout } from './EntityLayout'; diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/plugins/catalog/src/components/EntityLayout/types.ts new file mode 100644 index 0000000000..29ade88dc0 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; diff --git a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx new file mode 100644 index 0000000000..98b8295047 --- /dev/null +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ReactNode } from 'react'; +import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; + +export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); + + return ( + + {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/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx index e328b64d44..9135b54638 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -13,15 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; +import { EntityContext } from '../../hooks/useEntity'; -export const EntityProvider = ({ children }: { children: ReactNode }) => { - 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/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/Router.tsx b/plugins/catalog/src/components/Router.tsx index 298aa28d52..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 = () => ( @@ -79,9 +79,9 @@ export const Router = ({ + - + } /> + import('./components/CatalogPage').then(m => m.CatalogPage), + mountPoint: catalogRouteRef, + }), +); + +export const CatalogEntityPage = plugin.provide( + createRoutableExtension({ + component: () => + import('./components/CatalogEntityPage/CatalogEntityPage').then( + m => m.CatalogEntityPage, + ), + mountPoint: entityRouteRef, + }), +); diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts index 29ca63904d..289c4e26aa 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -55,7 +55,7 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { }; /** - * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. + * Grab Entity from the context and its current loading state. */ export const useEntity = () => { const { entity, loading, error } = useContext<{ diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 5949209128..2152933daf 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -15,10 +15,14 @@ */ export * from '@backstage/catalog-client'; -export { AboutCard, IconLinkVertical } from './components/AboutCard'; +export { AboutCard } from './components/AboutCard'; export { EntityPageLayout } from './components/EntityPageLayout'; +export { EntityLayout } from './components/EntityLayout'; +export { EntityProvider } from './components/EntityProvider'; +export * from './components/EntitySwitch'; export { Router } from './components/Router'; export { useEntityCompoundName } from './components/useEntityCompoundName'; export { EntityContext, useEntity } from './hooks/useEntity'; export { catalogApiRef, plugin } from './plugin'; export * from './routes'; +export * from './extensions'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 5f737ec12d..0c2b3b18c5 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -21,6 +21,7 @@ import { createPlugin, discoveryApiRef, } from '@backstage/core'; +import { catalogRouteRef, entityRouteRef } from './routes'; export const catalogApiRef = createApiRef({ id: 'plugin.catalog.service', @@ -37,4 +38,8 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), }), ], + routes: { + catalogIndex: catalogRouteRef, + catalogEntity: entityRouteRef, + }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index a39883e92b..2983b464ac 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -24,11 +24,14 @@ export const rootRoute = createRouteRef({ path: '', title: 'Catalog', }); +export const catalogRouteRef = rootRoute; + export const entityRoute = createRouteRef({ icon: NoIcon, path: ':namespace/:kind/:name/*', title: 'Entity', }); +export const entityRouteRef = entityRoute; // Utility function to get suitable route params for entityRoute, given an // entity instance diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index d52e5d7414..8e0b4743a0 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/plugin-catalog": "^0.2.7", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 80e7404831..7cac2e6a68 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/plugin-catalog": "^0.2.7", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -49,9 +49,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index e664883910..666a850f56 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-cost-insights +## 0.5.6 + +### Patch Changes + +- 9e9504ce4: Minor wording change in UI + +## 0.5.5 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + +## 0.5.4 + +### Patch Changes + +- 3fca9adb9: Fix links in sample instructions +- Updated dependencies [a08c32ced] + - @backstage/core@0.4.3 + +## 0.5.3 + +### Patch Changes + +- c02defd57: Make alert url field optional + ## 0.5.2 ### Patch Changes diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 886efa8eb6..dae68a5ce2 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -110,4 +110,4 @@ The CostInsightsApi `getAlerts` method may return any type of alert or recommend The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations. -The Alert `url` should link to documentation or instructions for resolving the alert. +The Alert `url` should link to documentation or instructions for resolving the alert. This may be omitted if no external link is needed. diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index d03d698a69..fd0b0e8e7d 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.5.2", + "version": "0.5.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.1", - "@backstage/test-utils": "^0.1.5", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -56,9 +55,9 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 26676be8b7..5e94c4647b 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -61,6 +61,8 @@ export type CostInsightsApi = { * if a user has multiple groups, they are able to switch between groups to see costs for each. * * This method should be removed once the Backstage identity plugin provides the same concept. + * + * @param userId The login id for the current user */ getUserGroups(userId: string): Promise; @@ -68,11 +70,13 @@ export type CostInsightsApi = { * Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a * similar concept in billing accounts). These act as filters for the displayed costs, users can * choose whether they see all costs for a group, or those from a particular owned project. + * + * @param group The group id from getUserGroups or query parameters */ getGroupProjects(group: string): Promise; /** - * Get daily cost aggregations for a given group and interval timeframe. + * Get daily cost aggregations for a given group and interval time frame. * * The return type includes an array of daily cost aggregations as well as statistics about the * change in cost over the intervals. Calculating these statistics requires us to bucket costs @@ -90,7 +94,7 @@ export type CostInsightsApi = { /** * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar - * concept in billing accounts) and interval timeframe. + * concept in billing accounts) and interval time frame. * * The return type includes an array of daily cost aggregations as well as statistics about the * change in cost over the intervals. Calculating these statistics requires us to bucket costs @@ -107,7 +111,7 @@ export type CostInsightsApi = { getProjectDailyCost(project: string, intervals: string): Promise; /** - * Get aggregations for a particular metric and interval timeframe. Teams + * Get aggregations for a particular metric and interval time frame. Teams * can see metrics important to their business in comparison to the growth * (or reduction) of a project or group's daily costs. * @@ -118,7 +122,7 @@ export type CostInsightsApi = { getDailyMetricData(metric: string, intervals: string): Promise; /** - * Get cost aggregations for a particular cloud product and interval timeframe. This includes + * Get cost aggregations for a particular cloud product and interval time frame. This includes * total cost for the product, as well as a breakdown of particular entities that incurred cost * in this product. The type of entity depends on the product - it may be deployed services, * storage buckets, managed database instances, etc. @@ -129,7 +133,8 @@ export type CostInsightsApi = { * The time period is supplied as a Duration rather than intervals, since this is always expected * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). * - * @param options Options to use when fetching insights for a particular cloud product and interval timeframe. + * @param options Options to use when fetching insights for a particular cloud product and + * interval time frame. */ getProductInsights(options: ProductInsightsOptions): Promise; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx new file mode 100644 index 0000000000..54d350298c --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { AlertInsightsSection } from './AlertInsightsSection'; +import { render } from '@testing-library/react'; +import { Alert } from '../../types'; +import { MockScrollProvider } from '../..'; + +const mockAlert: Alert = { + element:
, + subtitle: + 'Wherefore was I to this keen mockery born? When at your hands did I deserve this scorn?', + title: 'Mock alert', + url: '/cost-insights/test', +}; + +describe('', () => { + it('Renders alert without exploding', () => { + const { getByText } = render( + + + , + ); + expect(getByText(mockAlert.title)).toBeInTheDocument(); + expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); + expect(getByText('View Instructions')).toBeInTheDocument(); + }); + + it('Hides instructions button if url is not provided', () => { + const alert = { + ...mockAlert, + url: undefined, + }; + const { queryByText } = render( + + + , + ); + expect(queryByText('View Instructions')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index 426e943092..7e371c8001 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -34,12 +34,13 @@ export const AlertInsightsSection = ({ subtitle={alert.subtitle} number={number} /> - - - {/* */} - + {alert.url && ( + + + + )} {alert.element} ); diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx index 3442de49fc..c3f4b4f79c 100644 --- a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; -import { CodeSnippet } from '@backstage/core'; +import { CodeSnippet, Link } from '@backstage/core'; import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; export const LabelDataflowInstructionsPage = () => { @@ -31,10 +31,10 @@ export const LabelDataflowInstructionsPage = () => { In Cloud Dataflow, labels can be added to a job either programmatically - or via the command-line when launching a job. Note that GCP has - + or via the command-line when launching a job. Note that GCP has{' '} + restrictions - {' '} + {' '} on the length and characters that can be used in labels. @@ -46,9 +46,9 @@ export const LabelDataflowInstructionsPage = () => { DataflowPipelineOptions Dataflow jobs using Beam's{' '} - + DataflowPipelineOptions - {' '} + {' '} directly can use the setLabels function to add one or more labels: "my-dataflow-job For more information on specifying options, see the{' '} - + Dataflow documentation - {' '} + {' '} or{' '} - + Scio Scaladoc - + . diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx index 45d0946f86..3719bc8f6c 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -170,7 +170,7 @@ describe('', () => { } it('should render each product panel', async () => { - const noComputeEngineCostsRgx = /There are no Compute Engine costs within this timeframe for your team's projects./; + const noComputeEngineCostsRgx = /There are no Compute Engine costs within this time frame for your team's projects./; const { getByText } = await renderInContext( ', () => { aggregation: [0, 0], change: { ratio: 0, amount: 0 }, }; - const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const subheader = `There are no ${MockComputeEngine.name} costs within this time frame for your team's projects.`; const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 534ef490c3..1d15b4f427 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -132,7 +132,7 @@ export const ProductInsightsCard = ({ /> ) : ( - There are no {product.name} costs within this timeframe for your + There are no {product.name} costs within this time frame for your team's projects. )} diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index 4fcc34c87f..02b66b0fd1 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -135,7 +135,7 @@ export const ProjectGrowthInstructionsPage = () => { Next, evaluate whether the growth is significant. This helps avoid premature optimization, where cost in engineering time is more than - would be saved from the optimization over a reasonable timeframe. + would be saved from the optimization over a reasonable time frame. We recommend reframing the cost growth itself in terms of engineering @@ -205,7 +205,7 @@ export const ProjectGrowthInstructionsPage = () => { Is the workload using cloud resources efficiently? For compute resources, do the utilization metrics look reasonable? Autoscaling infrastructure, such as Kubernetes, can run workloads more efficiently - without comprimising reliability. + without compromising reliability. Lifecycle diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index c5c0463d99..18b86c8cb8 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -25,7 +25,7 @@ import { Maybe } from './Maybe'; export type Alert = { title: string; subtitle: string; - url: string; + url?: string; buttonText?: string; // Default: View Instructions element: JSX.Element; }; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 7a330b6f91..6eebead1d7 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -24,7 +24,7 @@ export const DEFAULT_DURATION = Duration.P30D; * Derive the start date of a given period, assuming two repeating intervals. * * @param duration see comment on Duration enum - * @param endDate from CostInsightsApi.getLastCompleteBillingDate + 1 day + * @param exclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate + 1 day */ export function inclusiveStartDateOf( duration: Duration, diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index ce9e65f1c3..19f9676eea 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -234,14 +234,24 @@ export function aggregationFor( 'day', ); + function nextDelta(): number { + const varianceFromBaseline = 0.15; + // Let's give positive vibes in trendlines - higher change for positive delta with >0.5 value + const positiveTrendChance = 0.55; + const normalization = positiveTrendChance - 1; + return baseline * (Math.random() + normalization) * varianceFromBaseline; + } + return [...Array(days).keys()].reduce( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; + const date = dayjs(inclusiveStartDateOf(duration, endDate)) + .add(i, 'day') + .format(DEFAULT_DATE_FORMAT); + const amount = Math.max(0, last + nextDelta()); values.push({ - date: dayjs(inclusiveStartDateOf(duration, endDate)) - .add(i, 'day') - .format(DEFAULT_DATE_FORMAT), - amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), + date: date, + amount: amount, }); return values; }, diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 4ba7d93887..5c109d6a9e 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,9 +42,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/fossa/.eslintrc.js b/plugins/fossa/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/fossa/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md new file mode 100644 index 0000000000..e1c9ef1717 --- /dev/null +++ b/plugins/fossa/CHANGELOG.md @@ -0,0 +1,9 @@ +# @backstage/plugin-fossa + +## 0.1.1 + +### Patch Changes + +- 7afdfef98: Bump dependency versions of @backstage/core, cli and test-utils +- Updated dependencies [a08c32ced] + - @backstage/core@0.4.3 diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md new file mode 100644 index 0000000000..489aef90ab --- /dev/null +++ b/plugins/fossa/README.md @@ -0,0 +1,79 @@ +# FOSSA Plugin + +The FOSSA Plugin displays code statistics from [FOSSA](https://fossa.com/). + +![FOSSA Card](./docs/fossa-card.png) + +## Getting Started + +1. Install the FOSSA Plugin: + +```bash +# packages/app + +yarn add @backstage/plugin-fossa +``` + +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as Fossa } from '@backstage/plugin-fossa'; +``` + +3. Add the `FossaCard` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { FossaCard } from '@backstage/plugin-fossa'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +4. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/fossa': + target: https://app.fossa.io/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'token ' + $env: FOSSA_AUTH_HEADER + +# if you have a fossa organization, configure your id here +fossa: + organizationId: +``` + +5. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) + +6. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + fossa.io/project-name: YOUR_PROJECT_NAME +spec: + type: library + owner: CNCF + lifecycle: experimental +``` diff --git a/plugins/fossa/config.d.ts b/plugins/fossa/config.d.ts new file mode 100644 index 0000000000..744a1cb52d --- /dev/null +++ b/plugins/fossa/config.d.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + fossa?: { + /** + * The organization id in fossa. + * @visibility frontend + */ + organizationId: string; + }; +} diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx new file mode 100644 index 0000000000..7846324b25 --- /dev/null +++ b/plugins/fossa/dev/index.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { + Content, + createPlugin, + createRouteRef, + Header, + Page, +} from '@backstage/core'; +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { FossaApi, fossaApiRef } from '../src/api'; +import { FossaCard } from '../src'; +import { Entity } from '@backstage/catalog-model'; +import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; + +createDevApp() + .registerApi({ + api: fossaApiRef, + deps: {}, + factory: () => + ({ + getFindingSummary: async projectTitle => { + switch (projectTitle) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'zero-deps': + return { + timestamp: new Date().toISOString(), + issueCount: 0, + dependencyCount: 0, + projectDefaultBranch: 'master', + projectUrl: `/#${projectTitle}`, + }; + + case 'issues': + return { + timestamp: new Date().toISOString(), + issueCount: 5, + dependencyCount: 100, + projectDefaultBranch: 'develop', + projectUrl: `/#${projectTitle}`, + }; + + case 'no-issues': + return { + timestamp: new Date().toISOString(), + issueCount: 0, + dependencyCount: 150, + projectDefaultBranch: 'feat/fossa', + projectUrl: `/#${projectTitle}`, + }; + + default: + return undefined; + } + }, + } as FossaApi), + }) + .registerPlugin( + createPlugin({ + id: 'fossa-demo', + register({ router }) { + const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [FOSSA_PROJECT_NAME_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + + const ExamplePage = () => ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ); + + router.addRoute( + createRouteRef({ path: '/', title: 'Fossa' }), + ExamplePage, + ); + }, + }), + ) + .render(); diff --git a/plugins/fossa/docs/fossa-card.png b/plugins/fossa/docs/fossa-card.png new file mode 100644 index 0000000000..227d847029 Binary files /dev/null and b/plugins/fossa/docs/fossa-card.png differ diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json new file mode 100644 index 0000000000..3036554fad --- /dev/null +++ b/plugins/fossa/package.json @@ -0,0 +1,62 @@ +{ + "name": "@backstage/plugin-fossa", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/fossa" + }, + "keywords": [ + "backstage", + "fossa" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.6.0", + "@backstage/core": "^0.4.3", + "@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", + "cross-fetch": "^3.0.6", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts new file mode 100644 index 0000000000..84406e1b1a --- /dev/null +++ b/plugins/fossa/src/api/FossaApi.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; + +export interface FindingSummary { + timestamp: string; + issueCount: number; + dependencyCount: number; + projectDefaultBranch: string; + projectUrl: string; +} + +export const fossaApiRef = createApiRef({ + id: 'plugin.fossa.service', + description: 'Used by the Fossa plugin to make requests', +}); + +export type FossaApi = { + getFindingSummary(projectTitle: string): Promise; +}; diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts new file mode 100644 index 0000000000..c01dd09327 --- /dev/null +++ b/plugins/fossa/src/api/FossaClient.test.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 { UrlPatternDiscovery } from '@backstage/core'; +import { msw } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { FindingSummary, FossaApi, FossaClient } from './index'; + +const server = setupServer(); + +describe('FossaClient', () => { + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + let client: FossaApi; + + beforeEach(() => { + client = new FossaClient({ discoveryApi, organizationId: '8736' }); + }); + + it('should report finding summary', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&title=our-service&organizationId=8736', + ); + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should report finding summary without licensing_issue_count', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&title=our-service&organizationId=8736', + ); + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + default_branch: 'refs/master', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 100, + dependencyCount: 160, + projectDefaultBranch: 'refs/master', + projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should skip organizationId', async () => { + client = new FossaClient({ discoveryApi }); + + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&title=our-service', + ); + return res(ctx.status(404)); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should handle 404 status', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&title=our-service&organizationId=8736', + ); + return res(ctx.status(404)); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); +}); diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts new file mode 100644 index 0000000000..10f688393b --- /dev/null +++ b/plugins/fossa/src/api/FossaClient.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { FindingSummary, FossaApi } from './FossaApi'; + +export class FossaClient implements FossaApi { + discoveryApi: DiscoveryApi; + organizationId?: string; + + constructor({ + discoveryApi, + organizationId, + }: { + discoveryApi: DiscoveryApi; + organizationId?: string; + }) { + this.discoveryApi = discoveryApi; + this.organizationId = organizationId; + } + + private async callApi(path: string): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`; + const response = await fetch(`${apiUrl}/${path}`); + if (response.status === 200) { + return await response.json(); + } + return undefined; + } + + async getFindingSummary( + projectTitle: string, + ): Promise { + const project = await this.callApi( + `projects?count=1&title=${projectTitle}${ + this.organizationId ? `&organizationId=${this.organizationId}` : '' + }`, + ); + if (!project) { + return undefined; + } + + const revision = project[0].revisions[0]; + return { + timestamp: revision.updatedAt, + issueCount: + revision.unresolved_licensing_issue_count || + revision.unresolved_issue_count, + dependencyCount: revision.dependency_count, + projectDefaultBranch: project[0].default_branch, + projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( + project[0].locator, + )}`, + }; + } +} diff --git a/plugins/fossa/src/api/index.ts b/plugins/fossa/src/api/index.ts new file mode 100644 index 0000000000..f31c4bd1d5 --- /dev/null +++ b/plugins/fossa/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { FossaApi, FindingSummary } from './FossaApi'; +export { fossaApiRef } from './FossaApi'; +export { FossaClient } from './FossaClient'; diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx new file mode 100644 index 0000000000..d1ac06d365 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -0,0 +1,178 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + EmptyState, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import { useAsync } from 'react-use'; +import { Entity } from '@backstage/catalog-model'; +import { fossaApiRef } from '../../api'; +import { makeStyles } from '@material-ui/core/styles'; +import { + FOSSA_PROJECT_NAME_ANNOTATION, + useProjectName, +} from '../useProjectName'; +import { Grid, Tooltip } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + numberError: { + fontSize: '5rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + margin: theme.spacing(2, 0), + color: theme.palette.error.main, + }, + numberSuccess: { + fontSize: '5rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + margin: theme.spacing(2, 0), + color: theme.palette.success.main, + }, + description: { + fontSize: '1rem', + textAlign: 'center', + fontWeight: theme.typography.fontWeightMedium, + color: theme.palette.text.secondary, + }, + disabled: { + backgroundColor: theme.palette.background.default, + }, + lastAnalyzed: { + color: theme.palette.text.secondary, + textAlign: 'center', + }, + branch: { + textDecoration: 'underline dotted', + }, +})); + +export const FossaCard = ({ + entity, + variant = 'gridItem', +}: { + entity: Entity; + variant?: string; +}) => { + const fossaApi = useApi(fossaApiRef); + + const projectTitle = useProjectName(entity); + + const { value, loading } = useAsync( + async () => + projectTitle ? fossaApi.getFindingSummary(projectTitle) : undefined, + [fossaApi, projectTitle], + ); + + const deepLink = value + ? { + title: 'View more', + link: value.projectUrl, + } + : undefined; + + const classes = useStyles(); + + return ( + <> + + {loading && } + + {!loading && !projectTitle && ( + + )} + + {!loading && projectTitle && !value && ( + + )} + + {value && ( + + +

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

+ {value.dependencyCount > 0 && ( +

Number of issues

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

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

+ )} +
+ + + Last analyzed on{' '} + {new Date(value.timestamp).toLocaleString('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + Based on {value.dependencyCount} Dependencies on branch{' '} + + + {value.projectDefaultBranch} + + + . + +
+ )} +
+ + ); +}; diff --git a/plugins/fossa/src/components/FossaCard/index.ts b/plugins/fossa/src/components/FossaCard/index.ts new file mode 100644 index 0000000000..5f84660bf0 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FossaCard } from './FossaCard'; diff --git a/plugins/fossa/src/components/index.ts b/plugins/fossa/src/components/index.ts new file mode 100644 index 0000000000..a49c71603b --- /dev/null +++ b/plugins/fossa/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './FossaCard'; diff --git a/plugins/fossa/src/components/useProjectName.ts b/plugins/fossa/src/components/useProjectName.ts new file mode 100644 index 0000000000..88379ca3fa --- /dev/null +++ b/plugins/fossa/src/components/useProjectName.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; + +export const FOSSA_PROJECT_NAME_ANNOTATION = 'fossa.io/project-name'; + +export const useProjectName = (entity: Entity): string | undefined => { + return ( + entity?.metadata.annotations?.[FOSSA_PROJECT_NAME_ANNOTATION] ?? undefined + ); +}; diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts new file mode 100644 index 0000000000..c35c534122 --- /dev/null +++ b/plugins/fossa/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin } from './plugin'; +export * from './components'; diff --git a/plugins/fossa/src/plugin.test.ts b/plugins/fossa/src/plugin.test.ts new file mode 100644 index 0000000000..4f9b00a02b --- /dev/null +++ b/plugins/fossa/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { plugin } from './plugin'; + +describe('fossa', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts new file mode 100644 index 0000000000..d1fe621018 --- /dev/null +++ b/plugins/fossa/src/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, +} from '@backstage/core'; +import { fossaApiRef, FossaClient } from './api'; + +export const plugin = createPlugin({ + id: 'fossa', + apis: [ + createApiFactory({ + api: fossaApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new FossaClient({ + discoveryApi, + organizationId: configApi.getOptionalString('fossa.organizationId'), + }), + }), + ], +}); diff --git a/plugins/fossa/src/setupTests.ts b/plugins/fossa/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/fossa/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 9de4a38d1d..f2202054db 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index d51c9bd489..2d69b1499d 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-github-actions +## 0.2.6 + +### Patch Changes + +- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [1dc445e89] + - @backstage/core-api@0.2.7 + - @backstage/core@0.4.2 + - @backstage/plugin-catalog@0.2.8 + ## 0.2.5 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index cfa30bfd77..94b69935d0 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,9 +33,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", - "@backstage/core-api": "^0.2.6", - "@backstage/plugin-catalog": "^0.2.7", + "@backstage/core": "^0.4.3", + "@backstage/core-api": "^0.2.7", + "@backstage/plugin-catalog": "^0.2.8", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,9 +51,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx index 390f35d516..d03c2d6be8 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -30,13 +30,13 @@ import { import React, { Suspense } from 'react'; import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; -import LinePart from 'react-lazylog/build/LinePart'; import { useProjectName } from '../useProjectName'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import DescriptionIcon from '@material-ui/icons/Description'; import { Entity } from '@backstage/catalog-model'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); +const LinePart = React.lazy(() => import('react-lazylog/build/LinePart')); const useStyles = makeStyles(() => ({ button: { diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 86a7befc77..28fead7796 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,9 +42,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index b14db4e8bb..9c1289febc 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-graphiql +## 0.2.4 + +### Patch Changes + +- 21f6be0f6: Patch release to force a release for the `esm.js` extension change for dynamically loading chunks + +## 0.2.3 + +### Patch Changes + +- 1dc445e89: Update to use new plugin extension API +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/core@0.4.2 + ## 0.2.2 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index af30bc4b12..341c4c14f7 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.2", + "version": "0.2.4", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/jenkins/package.json b/plugins/jenkins/package.json index 4c3799d550..99d69cf50e 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/plugin-catalog": "^0.2.7", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 649df8353f..56a61d12a2 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/plugin-kubernetes-backend": "^0.2.3", "@backstage/theme": "^0.2.2", "@kubernetes/client-node": "^0.12.1", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index 1f1d32937f..149172b7e8 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -2,9 +2,7 @@ A frontend for [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service), this plugin allows you to trigger Lighthouse audits on websites and track them over time. -## Getting Started - -### Use cases +## Introduction Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site. @@ -13,16 +11,27 @@ At Spotify, we keep track of Lighthouse audit scores over time to look at trends This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the top-level categories of Lighthouse at a glance. +You can learn more in our blog post [Introducing Lighthouse for Backstage](https://backstage.io/blog/2020/04/06/lighthouse-plugin). + +| List of audits | Specific audit | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| ![Screen shot of the main Lighthouse plugin page](images/lighthouse-page.png) | ![Screen shot of the resulting audit in the Lighthouse plugin](images/audit-view.png) | + In the future, we hope to add support for scheduling audits (which we do internally), as well as allowing custom runs of Lighthouse to be ingested (for auditing sites that require authentication or some session state). -### Installation +## Getting Started -To get started, you will need a running instance of [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). -_It's likely you will need to enable CORS when running lighthouse-audit-service. Initialize the app -with the environment variable `LAS_CORS` set to `true`._ +To get started, you will need a running instance of [`lighthouse-audit-service`](https://github.com/spotify/lighthouse-audit-service). +_It's likely you will need to [enable CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) to integrate with Backstage, so initialize the `lighthouse-audit-service` with the environment variable `LAS_CORS` set to `true`._ -When you have an instance running that Backstage can hook into, make sure to export the plugin in +When you have an instance running that Backstage can hook into, first install the plugin into your app: + +```sh +$ yarn add @backstage/plugin-lighthouse +``` + +Then make sure to export the plugin in your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: @@ -30,37 +39,31 @@ to enable the plugin: export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; ``` -Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in -your [`apis.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts). +Modify your app routes in `App.tsx` to include the Router component exported from the plugin, for example: -```js -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { Config } from '@backstage/config'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; +```tsx +// At the top imports +import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; -export const apis = (config: ConfigApi) => { - const builder = ApiRegistry.builder(); - - builder.add(lighthouseApiRef, LighthouseRestApi.fromConfig(config)); - - return builder.build() as ApiHolder; -} +// Inside App component + + // ... + } /> + // ... +; ``` -Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). +Then configure the `lighthouse-audit-service` URL in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). ```yaml lighthouse: baseUrl: http://your-service-url ``` -### Integration with the Catalog +## Integration with the Catalog -The lighthouse plugin can be integrated into the catalog so that lighthouse audit information relating to a component -can be displayed within that component's entity page. In order to link an Entity to its lighthouse audits the entity +The Lighthouse plugin can be integrated into the catalog so that Lighthouse audit information relating to a component +can be displayed within that component's entity page. In order to link an Entity to its Lighthouse audits, the entity must be annotated as follows: ```yaml @@ -73,9 +76,9 @@ metadata: lighthouse.com/website-url: # A single website url e.g. https://backstage.io/ ``` -> NOTE: The lighthouse plugin only supports one website URL per component at this time. +> NOTE: The plugin only supports one website URL per component at this time. -Add a lighthouse tab to the EntityPage: +Add a **Lighthouse tab** to the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx @@ -86,7 +89,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( // ... } /> @@ -97,7 +100,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( > NOTE: The embedded router renders page content without a header section allowing it to be rendered within a > catalog plugin page. -Add a Lighthouse card to the overview tab on the EntityPage: +Add a **Lighthouse card** to the overview tab on the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx @@ -119,3 +122,5 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); ``` + +Link Lighthouse diff --git a/plugins/lighthouse/images/audit-view.png b/plugins/lighthouse/images/audit-view.png new file mode 100644 index 0000000000..1c2fb863b5 Binary files /dev/null and b/plugins/lighthouse/images/audit-view.png differ diff --git a/plugins/lighthouse/images/lighthouse-page.png b/plugins/lighthouse/images/lighthouse-page.png new file mode 100644 index 0000000000..5b249edda1 Binary files /dev/null and b/plugins/lighthouse/images/lighthouse-page.png differ diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 93c4d53c21..f688f9c1db 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/core-api": "^0.2.6", "@backstage/plugin-catalog": "^0.2.7", "@backstage/theme": "^0.2.2", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/newrelic/package.json b/plugins/newrelic/package.json index 2984bba457..629dec635b 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/org/package.json b/plugins/org/package.json index c81b44756e..9db6fff348 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/plugin-catalog": "^0.2.7", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -33,9 +33,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index efcd8fbe37..ca7b4709b1 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-pagerduty +## 0.2.5 + +### Patch Changes + +- b7a124883: Optimize empty state image size. + +## 0.2.4 + +### Patch Changes + +- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] + - @backstage/core@0.4.2 + ## 0.2.3 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index aa6da07a02..1db99822a2 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.2.3", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,9 +44,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/pagerduty/src/assets/emptystate.svg b/plugins/pagerduty/src/assets/emptystate.svg index 57b35238dd..fa7f19123e 100644 --- a/plugins/pagerduty/src/assets/emptystate.svg +++ b/plugins/pagerduty/src/assets/emptystate.svg @@ -1,26 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/plugins/pagerduty/src/components/About/VerticalIcon.tsx b/plugins/pagerduty/src/components/About/VerticalIcon.tsx deleted file mode 100644 index 5463372ac0..0000000000 --- a/plugins/pagerduty/src/components/About/VerticalIcon.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import 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'; - -export type VerticalIconProps = { - icon?: React.ReactNode; - href?: string; - title?: string; - label: string; - action?: React.ReactNode; -}; - -const useIconStyles = makeStyles(theme => ({ - link: { - display: 'grid', - justifyItems: 'center', - gridGap: 4, - textAlign: 'center', - }, - label: { - fontSize: '0.7rem', - textTransform: 'uppercase', - fontWeight: 600, - letterSpacing: 1.2, - }, - linkStyle: { - color: theme.palette.secondary.main, - }, -})); - -export function VerticalIcon({ - icon = , - href = '#', - action, - ...props -}: VerticalIconProps) { - const classes = useIconStyles(); - - if (action) { - return ( - - {icon} - {action} - - ); - } - - // Absolute links should not be using RouterLink - if (href?.startsWith('//') || href?.includes('://')) { - return ( - - {icon} - {props.label} - - ); - } - - return ( - - {icon} - {props.label} - - ); -} diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx index 8fd20f37e8..5fb2cd7f4a 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -14,9 +14,16 @@ * limitations under the License. */ import React, { useState, useCallback } from 'react'; -import { useApi, Progress } from '@backstage/core'; +import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { Button, makeStyles } from '@material-ui/core'; +import { + Button, + makeStyles, + Card, + CardHeader, + Divider, + CardContent, +} from '@material-ui/core'; import { Incidents } from './Incident'; import { EscalationPolicy } from './Escalation'; import { useAsync } from 'react-use'; @@ -26,7 +33,6 @@ import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; import { TriggerDialog } from './TriggerDialog'; import { MissingTokenError } from './Errors/MissingTokenError'; import WebIcon from '@material-ui/icons/Web'; -import { AboutCard } from './About/AboutCard'; const useStyles = makeStyles({ triggerAlarm: { @@ -98,13 +104,13 @@ export const PagerDutyCard = ({ entity }: Props) => { } const serviceLink = { - title: 'Service Directory', + label: 'Service Directory', href: service!.url, icon: , }; const triggerLink = { - title: 'Create Incident', + label: 'Create Incident', action: ( + ); }; diff --git a/plugins/scaffolder/src/components/JobStage/LogModal.tsx b/plugins/scaffolder/src/components/JobStage/LogModal.tsx new file mode 100644 index 0000000000..73b6253510 --- /dev/null +++ b/plugins/scaffolder/src/components/JobStage/LogModal.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + IconButton, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Close from '@material-ui/icons/Close'; +import LazyLog from 'react-lazylog/build/LazyLog'; + +type Props = { + log: string[]; + open?: boolean; + onClose(): void; +}; + +const useStyles = makeStyles(theme => ({ + header: { + width: '100%', + padding: theme.spacing(1, 4), + }, + closeIcon: { + float: 'right', + padding: theme.spacing(0.5, 0), + }, + logs: { + boxShadow: '-3px -1px 7px 0px rgba(50, 50, 50, 0.59)', + height: '100%', + width: '100%', + }, +})); + +export const LogModal = ({ log, open = false, onClose }: Props) => { + const classes = useStyles(); + + return ( + + + Logs + + + + + +
+ +
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index 01a6346d24..d35385ec3b 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -23,18 +23,23 @@ import { LinearProgress, } from '@material-ui/core'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import { Job } from '../../types'; import { JobStage } from '../JobStage/JobStage'; type Props = { job: Job | null; toCatalogLink?: string; + open: boolean; + onModalClose: () => void; }; -export const JobStatusModal = ({ job, toCatalogLink }: Props) => { - const [isOpen, setOpen] = useState(true); - +export const JobStatusModal = ({ + job, + toCatalogLink, + open, + onModalClose, +}: Props) => { const renderTitle = () => { switch (job?.status) { case 'COMPLETED': @@ -50,13 +55,14 @@ export const JobStatusModal = ({ job, toCatalogLink }: Props) => { if (!job) { return; } + // Disallow closing modal if the job is in progress. if (job.status === 'COMPLETED' || job.status === 'FAILED') { - setOpen(false); + onModalClose(); } - }, [job]); + }, [job, onModalClose]); return ( - + {renderTitle()} {!job ? ( @@ -81,7 +87,7 @@ export const JobStatusModal = ({ job, toCatalogLink }: Props) => { )} {job?.status === 'FAILED' && ( - setOpen(false)}>Close + Close )} diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0754b64d7f..fd4e4c3ded 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -161,7 +161,12 @@ export const TemplatePage = () => { /> {loading && } - {modalOpen && } + setModalOpen(false)} + /> {template && ( { const scaffolderApi = useApi(scaffolderApiRef); const [currentJob, setCurrentJob] = useState(null); + + useEffect(() => { + const resetCurrentJob = async () => { + if (jobId) { + const job = await scaffolderApi.getJob(jobId); + setCurrentJob(job); + } + }; + + resetCurrentJob(); + }, [jobId, scaffolderApi]); + const shouldBeRunningInterval = jobId && currentJob?.status !== 'COMPLETED' && currentJob?.status !== 'FAILED'; + useInterval( async () => { if (jobId) { diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index f85c13b47c..8a97c926ad 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-search +## 0.2.5 + +### Patch Changes + +- 01707438b: Fix Material-UI warning for search filtering +- Updated dependencies [9c09a364f] + - @backstage/plugin-catalog@0.2.10 + ## 0.2.4 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index a66a9ba323..aae095dafe 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.7", + "@backstage/core": "^0.4.3", + "@backstage/plugin-catalog": "^0.2.10", "@backstage/catalog-model": "^0.6.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/search/src/components/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx index 7bd344e12e..888a583547 100644 --- a/plugins/search/src/components/Filters/Filters.tsx +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -103,7 +103,7 @@ export const Filters = ({ > {filterOptions.kind.map(filter => ( { const [showFilters, toggleFilters] = useState(false); const [selectedFilters, setSelectedFilters] = useState({ - selected: 'All', + selected: '', checked: [], }); @@ -146,7 +146,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { // apply filters // filter on selected - if (selectedFilters.selected !== 'All') { + if (selectedFilters.selected !== '') { withFilters = results.filter((result: Result) => selectedFilters.selected.includes(result.kind), ); @@ -192,7 +192,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { const resetFilters = () => { setSelectedFilters({ - selected: 'All', + selected: '', checked: [], }); }; @@ -259,7 +259,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { searchQuery={searchQuery} numberOfResults={filteredResults.length} numberOfSelectedFilters={ - (selectedFilters.selected !== 'All' ? 1 : 0) + + (selectedFilters.selected !== '' ? 1 : 0) + selectedFilters.checked.length } handleToggleFilters={() => toggleFilters(!showFilters)} diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index f5b5f5efd8..4ddcc5ced5 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-sentry +## 0.3.2 + +### Patch Changes + +- 88da267cc: Port to new composability API by exporting new `EntitySentryContent` and `EntitySentryCard` component extensions. +- Updated dependencies [9c09a364f] + - @backstage/plugin-catalog@0.2.10 + ## 0.3.1 ### Patch Changes diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index fc75008040..c9313911b2 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -15,24 +15,32 @@ */ import { Entity } from '@backstage/catalog-model'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; import { MockSentryApi, SentryApi, sentryApiRef, - SentryIssuesWidget, + EntitySentryCard, + EntitySentryContent, } from '../src'; import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SENTRY_PROJECT_SLUG_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() .registerApi({ api: sentryApiRef, @@ -56,52 +64,44 @@ createDevApp() }, } as SentryApi), }) - .registerPlugin( - createPlugin({ - id: 'sentry-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [SENTRY_PROJECT_SLUG_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'Sentry' }), - ExamplePage, - ); - }, - }), - ) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + ), + }) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + ), + }) .render(); diff --git a/plugins/sentry/docs/sentry-card.png b/plugins/sentry/docs/sentry-card.png index 30aad2a56e..29fefe32e2 100644 Binary files a/plugins/sentry/docs/sentry-card.png and b/plugins/sentry/docs/sentry-card.png differ diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 404ff90f34..6a1ef07436 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.1", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", + "@backstage/plugin-catalog": "^0.2.10", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,9 +46,9 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx new file mode 100644 index 0000000000..a66080f2b6 --- /dev/null +++ b/plugins/sentry/src/extensions.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + createComponentExtension, + createRoutableExtension, +} from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog'; +import { plugin, rootRouteRef } from './plugin'; + +export const EntitySentryContent = plugin.provide( + createRoutableExtension({ + mountPoint: rootRouteRef, + component: () => + import('./components/SentryIssuesWidget').then( + ({ SentryIssuesWidget }) => { + const SentryPage = () => { + const { entity } = useEntity(); + return ; + }; + return SentryPage; + }, + ), + }), +); + +export const EntitySentryCard = plugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SentryIssuesWidget').then( + ({ SentryIssuesWidget }) => { + const SentryCard = () => { + const { entity } = useEntity(); + return ; + }; + return SentryCard; + }, + ), + }, + }), +); diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index 2b9e2186ec..de53d7456a 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -17,4 +17,5 @@ export * from './api'; export * from './components'; export { plugin } from './plugin'; +export { EntitySentryCard, EntitySentryContent } from './extensions'; export { Router } from './components/Router'; diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 6a4fe4e5a4..49ecfdb5f2 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -41,4 +41,7 @@ export const plugin = createPlugin({ ), }), ], + routes: { + root: rootRouteRef, + }, }); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index c5d6433871..8ca178f9fe 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 246ea9c8f7..329a78ede7 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-tech-radar +## 0.3.2 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list +- bc909178d: Updated example data in `README`. + ## 0.3.1 ### Patch Changes diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index cbe4dd22eb..21f90ec432 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -83,13 +83,20 @@ const getHardCodedData = () => rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], entries: [ { - moved: 0, - ring: 'use', url: '#', key: 'github-actions', id: 'github-actions', title: 'GitHub Actions', quadrant: 'infrastructure', + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat', + }, + ], }, ], }); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 471f195381..326b0fe732 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.3.1", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.1", - "@backstage/test-utils": "^0.1.5", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 9d57ebc481..d83ff92fd4 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,87 @@ # @backstage/plugin-techdocs-backend +## 0.5.2 + +### Patch Changes + +- 7ec525481: 1. Added option to use AWS S3 as a choice to store the static generated files for TechDocs. +- Updated dependencies [7ec525481] +- Updated dependencies [5ecd50f8a] +- Updated dependencies [f8ba88ded] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/techdocs-common@0.3.2 + - @backstage/backend-common@0.4.2 + +## 0.5.1 + +### Patch Changes + +- 8804e8981: Using @backstage/integration package for GitHub/GitLab/Azure tokens and request options. + + Most probably you do not have to make any changes in the app because of this change. + However, if you are using the `DirectoryPreparer` or `CommonGitPreparer` exported by + `@backstage/techdocs-common` package, you now need to add pass in a `config` (from `@backstage/config`) + instance as argument. + + ``` + + const directoryPreparer = new DirectoryPreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + + const directoryPreparer = new DirectoryPreparer(config, logger); + const commonGitPreparer = new CommonGitPreparer(config, logger); + ``` + +- 359f9d2d8: Added configuration schema for the commonly used properties of techdocs and techdocs-backend plugins +- Updated dependencies [8804e8981] + - @backstage/techdocs-common@0.3.1 + +## 0.5.0 + +### Minor Changes + +- a8573e53b: techdocs-backend: Simplified file, removing individual preparers and generators. + techdocs-backend: UrlReader is now available to use in preparers. + + In your Backstage app, `packages/backend/plugins/techdocs.ts` file has now been simplified, + to remove registering individual preparers and generators. + + Please update the file when upgrading the version of `@backstage/plugin-techdocs-backend` package. + + ```typescript + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + const generators = await Generators.fromConfig(config, { + logger, + }); + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + ``` + + You should be able to remove unnecessary imports, and just do + + ```typescript + import { + createRouter, + Preparers, + Generators, + Publisher, + } from '@backstage/plugin-techdocs-backend'; + ``` + +### Patch Changes + +- Updated dependencies [a8573e53b] + - @backstage/techdocs-common@0.3.0 + ## 0.4.0 ### Minor Changes diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts new file mode 100644 index 0000000000..b51d86138a --- /dev/null +++ b/plugins/techdocs-backend/config.d.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * techdocs schema below is an abstract of what's used within techdocs-backend and for its visibility + * to view the complete techdoc schema please refer: plugins/techdocs/config.d.ts + * */ +export interface Config { + /** Configuration options for the techdocs-backend plugin */ + techdocs: { + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + */ + storageUrl: string; + /** + * documentation building process depends on the builder attr + * attr: 'builder' - accepts a string value + * e.g. builder: 'local' + * alternative: 'external' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + builder: 'local' | 'external'; + /** + * techdocs publisher information + */ + publisher: { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'local' + * aleternatives: 'googleGcs' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'local' | 'googleGcs' | 'awsS3'; + }; + }; +} diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 5552d87f3f..12f1646517 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.4.0", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.4.2", "@backstage/catalog-model": "^0.6.0", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.2.0", + "@backstage/techdocs-common": "^0.3.2", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -44,10 +44,12 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.4.5", "supertest": "^4.0.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 67d0dfb89e..863f587a96 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -16,6 +16,7 @@ import Docker from 'dockerode'; import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import { PreparerBuilder, PublisherBase, @@ -40,6 +41,7 @@ type DocsBuilderArguments = { entity: Entity; logger: Logger; dockerClient: Docker; + config: Config; }; export class DocsBuilder { @@ -49,6 +51,7 @@ export class DocsBuilder { private entity: Entity; private logger: Logger; private dockerClient: Docker; + private config: Config; constructor({ preparers, @@ -57,6 +60,7 @@ export class DocsBuilder { entity, logger, dockerClient, + config, }: DocsBuilderArguments) { this.preparer = preparers.get(entity); this.generator = generators.get(entity); @@ -64,6 +68,7 @@ export class DocsBuilder { this.entity = entity; this.logger = logger; this.dockerClient = dockerClient; + this.config = config; } public async build() { @@ -109,7 +114,11 @@ export class DocsBuilder { // Unless docs are stored locally const nonAgeCheckTypes = ['dir', 'file', 'url']; if (!nonAgeCheckTypes.includes(type)) { - const lastCommit = await getLastCommitTimestamp(target, this.logger); + const lastCommit = await getLastCommitTimestamp( + target, + this.config, + this.logger, + ); const storageTimeStamp = buildMetadataStorage.getTimestamp(); // Check if documentation source is newer than what we have diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index a64f581a1e..c2ecaab61b 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -131,50 +131,56 @@ export async function createRouter({ dockerClient, logger, entity, + config, }); - if (publisherType === 'local') { - if (!(await docsBuilder.docsUpToDate())) { - await docsBuilder.build(); - } - } else if (publisherType === 'googleGcs') { - // This block should be valid for all external storage implementations. So no need to duplicate in future, - // add the publisher type in the list here. - if (!(await publisher.hasDocsBeenGenerated(entity))) { - logger.info( - 'No pre-generated documentation files found for the entity in the storage. Building docs...', - ); - await docsBuilder.build(); - // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched - // on the user's page. If not, respond with a message asking them to check back later. - // The delay here is to make sure GCS registers newly uploaded files which is usually <1 second - let foundDocs = false; - for (let attempt = 0; attempt < 5; attempt++) { - if (await publisher.hasDocsBeenGenerated(entity)) { - foundDocs = true; - break; - } - await new Promise(r => setTimeout(r, 1000)); + switch (publisherType) { + case 'local': + if (!(await docsBuilder.docsUpToDate())) { + await docsBuilder.build(); } - if (!foundDocs) { - logger.error( - 'Published files are taking longer to show up in storage. Something went wrong.', + break; + case 'awsS3': + case 'googleGcs': + // This block should be valid for all external storage implementations. So no need to duplicate in future, + // add the publisher type in the list here. + if (!(await publisher.hasDocsBeenGenerated(entity))) { + logger.info( + 'No pre-generated documentation files found for the entity in the storage. Building docs...', ); - res - .status(408) - .send( - 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + await docsBuilder.build(); + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched + // on the user's page. If not, respond with a message asking them to check back later. + // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second + let foundDocs = false; + for (let attempt = 0; attempt < 5; attempt++) { + if (await publisher.hasDocsBeenGenerated(entity)) { + foundDocs = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!foundDocs) { + logger.error( + 'Published files are taking longer to show up in storage. Something went wrong.', ); - return; + res + .status(408) + .send( + 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + ); + return; + } + } else { + logger.info( + 'Found pre-generated docs for this entity. Serving them.', + ); + // TODO: re-trigger build for cache invalidation. + // Compare the date modified of the requested file on storage and compare it against + // the last modified or last commit timestamp in the repository. + // Without this, docs will not be re-built once they have been generated. } - } else { - logger.info( - 'Found pre-generated docs for this entity. Serving them.', - ); - // TODO: re-trigger build for cache invalidation. - // Compare the date modified of the requested file on storage and compare it against - // the last modified or last commit timestamp in the repository. - // Without this, docs will not be re-built once they have been generated. - } + break; + default: } } diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 3faf736ca0..721c10a1ec 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -52,14 +52,14 @@ export async function startStandaloneServer( logger.debug('Creating application...'); const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(logger); + const directoryPreparer = new DirectoryPreparer(config, logger); preparers.register('dir', directoryPreparer); const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); generators.register('techdocs', techdocsGenerator); - const publisher = Publisher.fromConfig(config, logger, discovery); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); const dockerClient = new Docker(); diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index d7bb8506b2..8bd79e4139 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/plugin-techdocs +## 0.5.2 + +### Patch Changes + +- 359f9d2d8: Added configuration schema for the commonly used properties of techdocs and techdocs-backend plugins +- Updated dependencies [a08c32ced] +- Updated dependencies [7e0b8cac5] +- Updated dependencies [8804e8981] +- Updated dependencies [87c0c53c2] +- Updated dependencies [86c3c652a] +- Updated dependencies [27f2af935] + - @backstage/core-api@0.2.8 + - @backstage/core@0.4.3 + - @backstage/plugin-catalog@0.2.9 + - @backstage/techdocs-common@0.3.1 + +## 0.5.1 + +### Patch Changes + +- Updated dependencies [d681db2b5] +- Updated dependencies [1dc445e89] +- Updated dependencies [342270e4d] +- Updated dependencies [1dc445e89] +- Updated dependencies [a8573e53b] + - @backstage/core-api@0.2.7 + - @backstage/core@0.4.2 + - @backstage/test-utils@0.1.6 + - @backstage/plugin-catalog@0.2.8 + - @backstage/techdocs-common@0.3.0 + ## 0.5.0 ### Minor Changes diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts new file mode 100644 index 0000000000..ad2daeb9a3 --- /dev/null +++ b/plugins/techdocs/config.d.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. + */ + +export interface Config { + /** Configuration options for the techdocs plugin */ + techdocs: { + /** + * attr: 'requestUrl' - accepts a string value + * e.g. requestUrl: http://localhost:7000/api/techdocs + * @visibility frontend + */ + requestUrl: string; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + */ + storageUrl: string; + /** + * documentation building process depends on the builder attr + * attr: 'builder' - accepts a string value + * e.g. builder: 'local' + * alternative: 'external' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + * @visibility frontend + */ + builder: 'local' | 'external'; + + /** + * techdocs publisher information + */ + generators?: { + /** + * attr: 'techdocs' - accepts a string value + * e.g. type: 'docker' + * alternatives: 'local' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + techdocs: 'local' | 'docker'; + }; + + /** + * techdocs publisher information + */ + publisher?: + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'local' + * alternatives: 'googleGcs' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'local'; + } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'awsS3' + * alternatives: 'googleGcs' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'awsS3'; + + /** + * awsS3 required when 'type' is set to awsS3 + */ + awsS3?: { + /** + * Credentials used to access a storage bucket + * @visibility secret + */ + credentials: { + /** + * User access key id + * attr: 'accessKeyId' - accepts a string value + * @visibility secret + */ + accessKeyId: string; + /** + * User secret access key + * attr: 'secretAccessKey' - accepts a string value + * @visibility secret + */ + secretAccessKey: string; + }; + /** + * Cloud Storage Bucket Name + * attr: 'bucketName' - accepts a string value + * @visibility secret + */ + bucketName: string; + /** + * AWS Region + * attr: 'region' - accepts a string value + * @visibility secret + */ + region?: string; + }; + } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'googleGcs' + * alternatives: 'googleGcs' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'googleGcs'; + + /** + * googleGcs required when 'type' is set to googleGcs + */ + googleGcs?: { + /** + * API key used to write to a storage bucket. + * attr: 'credentials' - accepts a string value + * @visibility secret + */ + credentials: string; + /** + * GCP Project ID where the Cloud Storage Bucket is hosted. + * attr: 'projectId' - accepts a string value + * @visibility secret + */ + projectId: string; + /** + * Cloud Storage Bucket Name + * attr: 'bucketName' - accepts a string value + * @visibility secret + */ + bucketName: string; + }; + }; + }; +} diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 361825e71a..955b0f798c 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.5.0", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ }, "dependencies": { "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.1", - "@backstage/core-api": "^0.2.6", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/test-utils": "^0.1.5", + "@backstage/core": "^0.4.3", + "@backstage/core-api": "^0.2.8", + "@backstage/plugin-catalog": "^0.2.9", + "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", - "@backstage/techdocs-common": "^0.2.0", + "@backstage/techdocs-common": "^0.3.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,9 +50,9 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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", @@ -63,85 +63,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/techdocs", - "type": "object", - "properties": { - "techdocs": { - "type": "object", - "properties": { - "requestUrl": { - "type": "string", - "visibility": "frontend" - }, - "storageUrl": { - "type": "string", - "visibility": "backend" - }, - "generators": { - "type": "object", - "properties": { - "techdocs": { - "type": "string", - "visibility": "backend" - } - } - }, - "builder": { - "type": "string", - "visibility": "frontend" - }, - "publisher": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "local", - "visibility": "backend" - } - } - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "googleGcs", - "visibility": "backend" - }, - "googleGcs": { - "type": "object", - "properties": { - "credentials": { - "type": "string", - "visibility": "secret" - }, - "projectId": { - "type": "string", - "visibility": "secret" - }, - "bucketName": { - "type": "string", - "visibility": "secret" - } - } - } - } - } - ] - } - }, - "required": [ - "requestUrl", - "storageUrl", - "builder" - ] - } - } - } + "configSchema": "config.d.ts" } diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index fed2b4d3f7..38b26dd94c 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/plugins/welcome/package.json b/plugins/welcome/package.json index b8658ea2f8..d215028960 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.1", + "@backstage/core": "^0.4.3", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", - "@backstage/dev-utils": "^0.1.6", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.4.5", + "@backstage/dev-utils": "^0.1.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/scripts/create-github-release.js b/scripts/create-github-release.js new file mode 100755 index 0000000000..a9bfb9f5b3 --- /dev/null +++ b/scripts/create-github-release.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This script creates a release on GitHub for the Backstage repository. + * Given a git tag, it identifies the PR created by changesets which is responsible for creating + * the git tag. It then uses the PR description consisting of changelogs for packages as the + * release description. + * + * Example: + * + * Set GITHUB_TOKEN environment variable. + * + * (Dry Run mode, will create a DRAFT release, but will not publish it.) + * (Draft releases are visible to maintainers and do not notify users.) + * $ node scripts/get-release-description v0.4.1 + * + * This will open the git tree at this tag https://github.com/backstage/backstage/tree/v0.4.1 + * It will identify https://github.com/backstage/backstage/pull/3668 as the responsible changeset PR. + * And will use everything in the PR description under "Releases" section. + * + * (Production or GitHub Actions Mode) + * $ node scripts/get-release-description v0.4.1 true + * + * This will do the same steps as above, and will publish the Release with the description. + */ + +const { Octokit } = require('@octokit/rest'); + +// See Examples above to learn about these command line arguments. +const [TAG_NAME, BOOL_CREATE_RELEASE] = process.argv.slice(2); + +if (!BOOL_CREATE_RELEASE) { + console.log( + '\nRunning script in Dry Run mode. It will output details, will create a draft release but will NOT publish it.', + ); +} + +const GH_OWNER = 'backstage'; +const GH_REPO = 'backstage'; +const EXPECTED_COMMIT_MESSAGE = /^Merge pull request #(?[0-9]+) from backstage\/changeset-release\/master\n\nVersion Packages$/; + +// Initialize a GitHub client +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +// Get the message of the commit responsible for a tag +async function getCommitMessageUsingTagName(tagName) { + // Get the tag SHA using the provided tag name + const refData = await octokit.git.getRef({ + owner: GH_OWNER, + repo: GH_REPO, + ref: `tags/${tagName}`, + }); + if (refData.status !== 200) { + console.error('refData:'); + console.error(refData); + throw new Error( + 'Something went wrong when getting the tag SHA using tag name', + ); + } + const tagSha = refData.data.object.sha; + console.log(`SHA for the tag ${TAG_NAME} is ${tagSha}`); + + // Get the commit SHA using the tag SHA + const tagData = await octokit.git.getTag({ + owner: GH_REPO, + repo: GH_REPO, + tag_sha: tagSha, + }); + if (tagData.status !== 200) { + console.error('tagData:'); + console.error(tagData); + throw new Error( + 'Something went wrong when getting the commit SHA using tag SHA', + ); + } + const commitSha = tagData.data.object.sha; + console.log( + `The commit for the tag is https://github.com/backstage/backstage/commit/${commitSha}`, + ); + + // Get the commit message using the commit SHA + const commitData = await octokit.git.getCommit({ + owner: GH_OWNER, + repo: GH_REPO, + commit_sha: commitSha, + }); + if (commitData.status !== 200) { + console.error('commitData:'); + console.error(commitData); + throw new Error( + 'Something went wrong when getting the commit message using commit SHA', + ); + } + + // Example Commit Message + // Merge pull request #3555 from backstage/changeset-release/master Version Packages + return commitData.data.message; +} + +// There is a PR number in our expected commit message. Get the description of that PR. +async function getPrDescriptionFromCommitMessage(commitMessage) { + // It should exactly match the pattern of changeset commit message, or else will abort. + const expectedMessage = RegExp(EXPECTED_COMMIT_MESSAGE); + if (!expectedMessage.test(commitMessage)) { + throw new Error( + `Expected regex did not match commit message: ${commitMessage}`, + ); + } + + // Get the PR description from the commit message + const prNumber = commitMessage.match(expectedMessage).groups.prNumber; + console.log( + `Identified the changeset Pull request - https://github.com/backstage/backstage/pull/${prNumber}`, + ); + + const prData = await octokit.pulls.get({ + owner: GH_OWNER, + repo: GH_REPO, + pull_number: prNumber, + }); + + return prData.data.body; +} + +// Use the PR description to prepare for the release description +async function prepareReleaseDescription(prDescription) { + // TODO: Refine prDescription to remove the lines containing "Update Dependencies" + // Remove everything in the beginning until changelogs. + return prDescription.split('\n').slice(3).join('\n'); +} + +// Create Release on GitHub. +async function createRelease(releaseDescription) { + // Create draft release if BOOL_CREATE_RELEASE is undefined + // Publish release if BOOL_CREATE_RELEASE is not undefined + const boolCreateDraft = !BOOL_CREATE_RELEASE; + + const releaseResponse = await octokit.repos.createRelease({ + owner: GH_REPO, + repo: GH_REPO, + tag_name: TAG_NAME, + name: TAG_NAME, + body: releaseDescription, + draft: boolCreateDraft, + prerelease: false, + }); + + if (releaseResponse.status === 201) { + if (boolCreateDraft) { + console.log('Created draft release! Click Publish to notify users.'); + } else { + console.log('Published release!'); + } + console.log(releaseResponse.data.html_url); + } else { + console.error(releaseResponse); + throw new Error('Something went wrong when creating the release.'); + } +} + +async function main() { + const commitMessage = await getCommitMessageUsingTagName(TAG_NAME); + const prDescription = await getPrDescriptionFromCommitMessage(commitMessage); + const releaseDescription = await prepareReleaseDescription(prDescription); + + await createRelease(releaseDescription); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/create-release-tag.js b/scripts/create-release-tag.js new file mode 100755 index 0000000000..bcfe9153ff --- /dev/null +++ b/scripts/create-release-tag.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { Octokit } = require('@octokit/rest'); + +const baseOptions = { + owner: 'backstage', + repo: 'backstage', +}; + +async function main() { + const { GITHUB_SHA, GITHUB_TOKEN } = process.env; + if (!GITHUB_SHA) { + throw new Error('GITHUB_SHA is not set'); + } + if (!GITHUB_TOKEN) { + throw new Error('GITHUB_TOKEN is not set'); + } + + const octokit = new Octokit({ auth: GITHUB_TOKEN }); + + const date = new Date(); + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + const baseTagName = `release-${yyyy}-${mm}-${dd}`; + + console.log('Requesting existing tags'); + + const existingTags = await octokit.repos.listTags({ + ...baseOptions, + per_page: 100, + }); + const existingTagNames = existingTags.data.map(obj => obj.name); + + let tagName = baseTagName; + let index = 0; + while (existingTagNames.includes(tagName)) { + index += 1; + tagName = `${baseTagName}.${index}`; + } + + console.log(`Creating release tag ${tagName}`); + + const annotatedTag = await octokit.git.createTag({ + ...baseOptions, + tag: tagName, + message: tagName, + object: GITHUB_SHA, + type: 'commit', + }); + + await octokit.git.createRef({ + ...baseOptions, + ref: `refs/tags/${tagName}`, + sha: annotatedTag.data.sha, + }); + + console.log(`::set-output name=tag_name::${tagName}`); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/migrate-location-types.js b/scripts/migrate-location-types.js new file mode 100755 index 0000000000..eb2f5fb860 --- /dev/null +++ b/scripts/migrate-location-types.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This is a script that helps you migrate any locations that are using +// deprecated location types. Change the below URL to point towards your +// catalog API endpoint and execute the script. It will delete and add +// back the locations with the correct type one by one. + +const BASE_URL = 'http://localhost:7000/api/catalog'; // Change me + +const deprecatedTypes = [ + 'github', + 'github/api', + 'bitbucket/api', + 'gitlab/api', + 'azure/api', +]; + +async function main() { + const locations = await request('GET', `${BASE_URL}/locations`); + + for (const { data: location } of locations) { + if (!deprecatedTypes.includes(location.type)) { + continue; + } + console.log(`${location.type} -> url : ${location.target}`); + await request('DELETE', `${BASE_URL}/locations/${location.id}`); + try { + await request('POST', `${BASE_URL}/locations`, { + type: 'url', + target: location.target, + }); + } catch (error) { + console.log(`Failed to add back location ${location.target}`); + throw error; + } + } +} + +async function request(method, url, body) { + return new Promise((resolve, reject) => { + const parsedUrl = require('url').parse(url); + + const req = require('http').request( + { + method, + ...parsedUrl, + headers: body + ? { + 'Content-Type': 'application/json', + } + : {}, + }, + res => { + const chunks = []; + res.on('data', chunk => { + chunks.push(chunk); + }); + + res.on('end', () => { + if (res.statusCode >= 300) { + reject( + new Error( + `${method} to ${url} failed with status ${res.statusCode}`, + ), + ); + return; + } + try { + const body = Buffer.concat(chunks).toString('utf8').trim(); + if (body) { + resolve(JSON.parse(body)); + } else { + resolve(); + } + } catch (error) { + reject(error); + } + }); + }, + ); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index 60980ff41d..1e98fd0b7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1666,10 +1666,10 @@ yup "^0.29.3" "@backstage/core@^0.3.0": - version "0.4.1" + version "0.4.3" dependencies: "@backstage/config" "^0.1.2" - "@backstage/core-api" "^0.2.6" + "@backstage/core-api" "^0.2.8" "@backstage/theme" "^0.2.2" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -3937,9 +3937,9 @@ universal-user-agent "^6.0.0" "@octokit/openapi-types@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.0.tgz#6d8f8ad9db3b75a39115f5def2654df8bed39f28" - integrity sha512-J4bfM7lf8oZvEAdpS71oTvC1ofKxfEZgU5vKVwzZKi4QPiL82udjpseJwxPid9Pu2FNmyRQOX4iEj6W1iOSnPw== + version "2.0.1" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.1.tgz#7453d8281ce66b8ed1607f7ac7d751c3baffd2cc" + integrity sha512-9AuC04PUnZrjoLiw3uPtwGh9FE4Q3rTqs51oNlQ0rkwgE8ftYsOC+lsrQyvCvWm85smBbSc0FNRKKumvGyb44Q== "@octokit/plugin-enterprise-rest@^6.0.1": version "6.0.1" @@ -4364,11 +4364,6 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== -"@sindresorhus/is@^2.0.0": - version "2.1.1" - resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz#ceff6a28a5b4867c2dd4a1ba513de278ccbe8bb1" - integrity sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg== - "@sindresorhus/is@^3.1.1": version "3.1.2" resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.2.tgz#548650de521b344e3781fbdb0ece4aa6f729afb8" @@ -5126,7 +5121,7 @@ dependencies: defer-to-connect "^1.0.1" -"@szmarczak/http-timer@^4.0.0", "@szmarczak/http-timer@^4.0.5": +"@szmarczak/http-timer@^4.0.5": version "4.0.5" resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ== @@ -5269,10 +5264,10 @@ resolved "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== -"@types/archiver@^3.1.1": - version "3.1.1" - resolved "https://registry.npmjs.org/@types/archiver/-/archiver-3.1.1.tgz#10cc1be44af8911e57484342c7b3b32a5f178a1a" - integrity sha512-TzVZ9204sH1TuFylfr1cw/AA/3/VldAAXswEwKLXUOzA9mDg+m6gHF9EaqKNlozcjc6knX5m1KAqJzksPLSEfw== +"@types/archiver@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.0.tgz#869f4ce4028e49cf9a0243cf914415f4cc3d1f3d" + integrity sha512-baFOhanb/hxmcOd1Uey2TfFg43kTSmM6py1Eo7Rjbv/ivcl7PXLhY0QgXGf50Hx/eskGCFqPfhs/7IZLb15C5g== dependencies: "@types/glob" "*" @@ -5781,9 +5776,9 @@ pretty-format "^26.0.0" "@types/jquery@^3.3.34": - version "3.5.1" - resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" - integrity sha512-Tyctjh56U7eX2b9udu3wG853ASYP0uagChJcQJXLUXEU6C/JiW5qt5dl8ao01VRj1i5pgXPAf8f1mq4+FDLRQg== + version "3.5.5" + resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz#2c63f47c9c8d96693d272f5453602afd8338c903" + integrity sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w== dependencies: "@types/sizzle" "*" @@ -5838,7 +5833,7 @@ resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== -"@types/keyv@*", "@types/keyv@^3.1.1": +"@types/keyv@*": version "3.1.1" resolved "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== @@ -5963,13 +5958,6 @@ dependencies: "@types/node" "*" -"@types/nock@^11.1.0": - version "11.1.0" - resolved "https://registry.npmjs.org/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538" - integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw== - dependencies: - nock "*" - "@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" @@ -5998,20 +5986,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.11": - version "0.26.11" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.11.tgz#0cbc5e929f23e5ffc536920e3a887e0b3f46d1a4" - integrity sha512-BGrY9F8lBtfU+Ne1Pjb9k/PUsfSCUAqPXgKkTkM6mc5213H5VAoM12zG/sz/cr3jI3AXcngNbAYWlqSrXsWJug== - dependencies: - "@types/node" "*" - -"@types/nodegit@^0.26.12": - version "0.26.12" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.12.tgz#93afb4cb85d3a48d392c3232699c9c07d8251a36" - integrity sha512-4YpeTImFZNJ1cve4lEueHFVS8rAs8XpZqlmx+Bm9bMc+XMiCrcwaUf6peN7pod7Rl3esVlGP1zdBB7Z12eMVAA== - dependencies: - "@types/node" "*" - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -6029,20 +6003,6 @@ dependencies: "@types/node" "*" -"@types/openid-client@^3.7.0": - version "3.7.0" - resolved "https://registry.npmjs.org/@types/openid-client/-/openid-client-3.7.0.tgz#8406e12798d16083df09cc3625973f5a00dd57fa" - integrity sha512-hW+QbAeUyfUHABwUjUECOcv56Mf5tN29xK3GPN7wy3R3XfIQdkm37XELA4wbe2RNG9GYUpN8l2ytfoW05XmpgQ== - dependencies: - openid-client "*" - -"@types/ora@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz#b2f65d1283a8f36d8b0f9ee767e0732a2f429362" - integrity sha512-jll99xUKpiFbIFZSQcxm4numfsLaOWBzWNaRk3PvTSE7BPqTzzOCFmS0mQ7m8qkTfmYhuYbehTGsxkvRLPC++w== - dependencies: - ora "*" - "@types/overlayscrollbars@^1.9.0": version "1.12.0" resolved "https://registry.npmjs.org/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764" @@ -6398,9 +6358,9 @@ integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ== "@types/tar@^4.0.3": - version "4.0.3" - resolved "https://registry.npmjs.org/@types/tar/-/tar-4.0.3.tgz#e2cce0b8ff4f285293243f5971bd7199176ac489" - integrity sha512-Z7AVMMlkI8NTWF0qGhC4QIX0zkV/+y0J8x7b/RsHrN0310+YNjoJd8UrApCiGBCWtKjxS9QhNqLi2UJNToh5hA== + version "4.0.4" + resolved "https://registry.npmjs.org/@types/tar/-/tar-4.0.4.tgz#d680de60855e7778a51c672b755869a3b8d2889f" + integrity sha512-0Xv+xcmkTsOZdIF4yCnd7RkOOyfyqPaqJ7RZFKnwdxfDbkN3eAAE9sHl8zJFqBz4VhxolW9EErbjR1oyH7jK2A== dependencies: "@types/minipass" "*" "@types/node" "*" @@ -6549,13 +6509,6 @@ dependencies: "@types/node" "*" -"@types/yaml@^1.9.7": - version "1.9.7" - resolved "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz#2331f36e0aac91311a63d33eb026c21687729679" - integrity sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA== - dependencies: - yaml "*" - "@types/yargs-parser@*": version "15.0.0" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" @@ -7407,9 +7360,9 @@ archiver-utils@^2.1.0: readable-stream "^2.0.0" archiver@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/archiver/-/archiver-5.0.2.tgz#b2c435823499b1f46eb07aa18e7bcb332f6ca3fc" - integrity sha512-Tq3yV/T4wxBsD2Wign8W9VQKhaUxzzRmjEiSoOK0SLqPgDP/N1TKdYyBeIEu56T4I9iO4fKTTR0mN9NWkBA0sg== + version "5.1.0" + resolved "https://registry.npmjs.org/archiver/-/archiver-5.1.0.tgz#05b0f6f7836f3e6356a0532763d2bb91017a7e37" + integrity sha512-iKuQUP1nuKzBC2PFlGet5twENzCfyODmvkxwDV0cEFXavwcLrIW5ssTuHi9dyTPvpWr6Faweo2eQaQiLIwyXTA== dependencies: archiver-utils "^2.1.0" async "^3.2.0" @@ -7417,7 +7370,7 @@ archiver@^5.0.2: readable-stream "^3.6.0" readdir-glob "^1.0.0" tar-stream "^2.1.4" - zip-stream "^4.0.0" + zip-stream "^4.0.4" are-we-there-yet@~1.1.2: version "1.1.5" @@ -7439,6 +7392,11 @@ argparse@^1.0.10, argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + aria-query@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" @@ -7664,6 +7622,11 @@ async-limiter@~1.0.0: resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-lock@^1.1.0: + version "1.2.4" + resolved "https://registry.npmjs.org/async-lock/-/async-lock-1.2.4.tgz#80d0d612383045dd0c30eb5aad08510c1397cb91" + integrity sha512-UBQJC2pbeyGutIfYmErGc9RaJYnpZ1FHaxuKwb0ahvGiiCkPUf3p67Io+YLPmmv3RHY+mF6JEtNW8FlHsraAaA== + async-retry@^1.2.1: version "1.3.1" resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz#139f31f8ddce50c0870b0ba558a6079684aaed55" @@ -7733,6 +7696,21 @@ autoprefixer@^9.7.2: postcss "^7.0.32" postcss-value-parser "^4.1.0" +aws-sdk@^2.817.0: + version "2.817.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.817.0.tgz#3a97b690b0ec494cf8ee927affb3973cf26abcc8" + integrity sha512-DZIdWpkcqbqsCz0MEskHsyFaqc6Tk9XIFqXAg1AKHbOgC8nU45bz+Y2osX77pU01JkS/G7OhGtGmlKDrOPvFwg== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -7757,6 +7735,13 @@ axios@^0.20.0: dependencies: follow-redirects "^1.10.0" +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.0.2: version "2.1.2" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" @@ -8301,15 +8286,7 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bl@^1.0.0: - version "1.2.2" - resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" - integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - -bl@^4.0.1, bl@^4.0.3: +bl@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== @@ -8567,19 +8544,6 @@ btoa@^1.2.1: resolved "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g== -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -8590,11 +8554,6 @@ buffer-equal-constant-time@1.0.1: resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= - buffer-from@1.x, buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -8625,7 +8584,7 @@ buffer-xor@^1.0.3: resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@^4.3.0: +buffer@4.9.2, buffer@^4.3.0: version "4.9.2" resolved "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== @@ -8634,14 +8593,6 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.1.0: - version "5.7.1" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - buffer@^5.5.0: version "5.6.0" resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" @@ -8771,14 +8722,6 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-lookup@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz#87be64a18b925234875e10a9bb1ebca4adce6b38" - integrity sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg== - dependencies: - "@types/keyv" "^3.1.1" - keyv "^4.0.0" - cacheable-lookup@^5.0.3: version "5.0.3" resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" @@ -9086,7 +9029,7 @@ chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3. optionalDependencies: fsevents "~2.1.2" -chownr@^1.0.1, chownr@^1.1.1, chownr@^1.1.2: +chownr@^1.1.1, chownr@^1.1.2: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== @@ -9150,6 +9093,11 @@ clean-css@^4.2.3: dependencies: source-map "~0.6.0" +clean-git-ref@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz#dcc0ca093b90e527e67adb5a5e55b1af6816dcd9" + integrity sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -9528,13 +9476,13 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== -compress-commons@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.0.1.tgz#c5fa908a791a0c71329fba211d73cd2a32005ea8" - integrity sha512-xZm9o6iikekkI0GnXCmAl3LQGZj5TBDj0zLowsqi7tJtEa3FMGSEcHcqrSJIrOAk1UG/NBbDn/F1q+MG/p/EsA== +compress-commons@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.0.2.tgz#d6896be386e52f37610cef9e6fa5defc58c31bd7" + integrity sha512-qhd32a9xgzmpfoga1VQEiLEwdKZ6Plnpx5UCgIsf89FSolyJ7WnifY4Gtjgv5WR6hWAyRaHxC5MiEhU/38U70A== dependencies: buffer-crc32 "^0.2.13" - crc32-stream "^4.0.0" + crc32-stream "^4.0.1" normalize-path "^3.0.0" readable-stream "^3.6.0" @@ -9935,20 +9883,21 @@ cpy@^8.1.1: p-filter "^2.1.0" p-map "^3.0.0" -crc32-stream@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.0.tgz#05b7ca047d831e98c215538666f372b756d91893" - integrity sha512-tyMw2IeUX6t9jhgXI6um0eKfWq4EIDpfv5m7GX4Jzp7eVelQ360xd8EPXJhp2mHwLQIkqlnMLjzqSZI3a+0wRw== +crc-32@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== dependencies: - crc "^3.4.4" - readable-stream "^3.4.0" + exit-on-epipe "~1.0.1" + printj "~1.1.0" -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== +crc32-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.1.tgz#0f047d74041737f8a55e86837a1b826bd8ab0067" + integrity sha512-FN5V+weeO/8JaXsamelVYO1PHyeCsuL3HcG4cqsj0ceARcocxalaShCsohZMSAF+db7UYFwBy1rARK/0oFItUw== dependencies: - buffer "^5.1.0" + crc-32 "^1.2.0" + readable-stream "^3.4.0" create-ecdh@^4.0.0: version "4.0.3" @@ -9998,9 +9947,9 @@ create-react-context@0.3.0: warning "^4.0.3" cross-env@^7.0.0: - version "7.0.2" - resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.2.tgz#bd5ed31339a93a3418ac4f3ca9ca3403082ae5f9" - integrity sha512-KZP/bMEOJEDCkDQAyRhu3RL2ZO/SUVrxQVI0G3YEQ+OLbRA3c6zgixe8Mq8a/z7+HKlNEjo8oiLUs8iRijY2Rw== + version "7.0.3" + resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== dependencies: cross-spawn "^7.0.1" @@ -10744,13 +10693,6 @@ decompress-response@^4.2.0: dependencies: mimic-response "^2.0.0" -decompress-response@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-5.0.0.tgz#7849396e80e3d1eba8cb2f75ef4930f76461cb0f" - integrity sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw== - dependencies: - mimic-response "^2.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -11009,6 +10951,11 @@ diff-sequences@^26.6.2: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff3@0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz#d4e5c3a4cdf4e5fe1211ab42e693fcb4321580fc" + integrity sha1-1OXDpM305f4SEatC5pP8tDIVgPw= + diff@^4.0.1, diff@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -11817,10 +11764,10 @@ eslint-plugin-jsx-a11y@^6.2.1: has "^1.0.3" jsx-ast-utils "^2.2.1" -eslint-plugin-monorepo@^0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/eslint-plugin-monorepo/-/eslint-plugin-monorepo-0.2.1.tgz#96cfc4af241077675f40d7017377897fb8ea537b" - integrity sha512-82JaAjuajVAsDT+pMvdt275H6F55H3MEofaMZbJurGqfXpPDT4eayTgYyyjfd1XR8VD1S+ORbuHCULnSqNyD9g== +eslint-plugin-monorepo@^0.3.2: + version "0.3.2" + resolved "https://registry.npmjs.org/eslint-plugin-monorepo/-/eslint-plugin-monorepo-0.3.2.tgz#bc546cbe84b21ae6a7622f261bf9fe73b1524367" + integrity sha512-CypTAqHjTR05XxzqDj7x88oVu2GiqqQA/datD9kIwciHzpj0oE4YbTdyEFFKADgd7dbd21KliSlUpOvo626FBw== dependencies: eslint-module-utils "^2.1.1" get-monorepo-packages "^1.1.0" @@ -12058,6 +12005,11 @@ eventemitter3@^4.0.0: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== +events@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + events@3.1.0, events@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" @@ -12152,6 +12104,11 @@ exit-hook@^1.0.0: resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + exit@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -12904,7 +12861,7 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^7.0.0, fs-extra@^7.0.1: +fs-extra@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== @@ -13476,27 +13433,6 @@ google-p12-pem@^3.0.3: dependencies: node-forge "^0.10.0" -got@^10.7.0: - version "10.7.0" - resolved "https://registry.npmjs.org/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" - integrity sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg== - dependencies: - "@sindresorhus/is" "^2.0.0" - "@szmarczak/http-timer" "^4.0.0" - "@types/cacheable-request" "^6.0.1" - cacheable-lookup "^2.0.0" - cacheable-request "^7.0.1" - decompress-response "^5.0.0" - duplexer3 "^0.1.4" - get-stream "^5.0.0" - lowercase-keys "^2.0.0" - mimic-response "^2.1.0" - p-cancelable "^2.0.0" - p-event "^4.0.0" - responselike "^2.0.0" - to-readable-stream "^2.0.0" - type-fest "^0.10.0" - got@^11.5.2: version "11.6.0" resolved "https://registry.npmjs.org/got/-/got-11.6.0.tgz#4978c78f3cbc3a45ee95381f8bb6efd1db1f4638" @@ -13738,7 +13674,7 @@ graphql@^14.5.3: growly@^1.3.0: version "1.3.0" - resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= gtoken@^5.0.4: @@ -14342,6 +14278,11 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" +ieee754@1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -14825,9 +14766,9 @@ is-directory@^0.3.1: integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= is-docker@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b" - integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ== + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" + integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== is-dom@^1.0.9, is-dom@^1.1.0: version "1.1.0" @@ -15204,7 +15145,7 @@ isarray@^2.0.5: isexe@^2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= isobject@^2.0.0: @@ -15239,6 +15180,23 @@ isomorphic-form-data@~2.0.0: dependencies: form-data "^2.3.2" +isomorphic-git@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.8.0.tgz#50440650a64706a321cbea1af955c1cf1110b238" + integrity sha512-TWJvQh+++eFrEG0IFS/jLhMwsBoCOX1/Dsw9q8no59Mp1K0jEjSHXFWv2P04PwkxcIpePkXVBI5YFcFT2nkuQg== + dependencies: + async-lock "^1.1.0" + clean-git-ref "^2.0.1" + crc-32 "^1.2.0" + diff3 "0.0.3" + ignore "^5.1.4" + minimisted "^2.0.0" + pako "^1.0.10" + pify "^4.0.1" + readable-stream "^3.4.0" + sha.js "^2.4.9" + simple-get "^3.0.2" + isomorphic-ws@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" @@ -15744,6 +15702,11 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= + jose@^1.27.1: version "1.27.1" resolved "https://registry.npmjs.org/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8" @@ -15796,6 +15759,13 @@ js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.6.1, js-yaml@^3.8. argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -15982,7 +15952,7 @@ json3@^3.3.2: resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== -json5@2.x, json5@^2.1.0, json5@^2.1.1, json5@^2.1.2: +json5@2.x, json5@^2.1.1, json5@^2.1.2: version "2.1.3" resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== @@ -16971,7 +16941,7 @@ lru-cache@^5.0.0, lru-cache@^5.1.1: lru-cache@^6.0.0: version "6.0.0" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== dependencies: yallist "^4.0.0" @@ -17515,7 +17485,7 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== -mimic-response@^2.0.0, mimic-response@^2.1.0: +mimic-response@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== @@ -17585,6 +17555,13 @@ minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1. resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimisted@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz#d059fb905beecf0774bc3b308468699709805cb1" + integrity sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA== + dependencies: + minimist "^1.2.5" + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" @@ -17964,7 +17941,7 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" -nock@*, nock@^13.0.5: +nock@^13.0.5: version "13.0.5" resolved "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414" integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg== @@ -18028,23 +18005,6 @@ node-gyp@3.x: tar "^2.0.0" which "1" -node-gyp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-4.0.0.tgz#972654af4e5dd0cd2a19081b4b46fe0442ba6f45" - integrity sha512-2XiryJ8sICNo6ej8d0idXDEMKfVfFK7kekGCtJAuelGsYHQxhj13KTf95swTCN2dZ/4lTfZ84Fu31jqJEEgjWA== - dependencies: - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^4.4.8" - which "1" - node-gyp@^5.0.2: version "5.1.0" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-5.1.0.tgz#8e31260a7af4a2e2f994b0673d4e0b3866156332" @@ -18107,9 +18067,9 @@ node-modules-regexp@^1.0.0: integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= node-notifier@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz#a7eee2d51da6d0f7ff5094bc7108c911240c1620" - integrity sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA== + version "8.0.1" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.1.tgz#f86e89bbc925f2b068784b31f382afdc6ca56be1" + integrity sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA== dependencies: growly "^1.3.0" is-wsl "^2.2.0" @@ -18134,22 +18094,6 @@ node-pre-gyp@^0.11.0: semver "^5.3.0" tar "^4" -node-pre-gyp@^0.13.0: - version "0.13.0" - resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.13.0.tgz#df9ab7b68dd6498137717838e4f92a33fc9daa42" - integrity sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.52, node-releases@^1.1.58: version "1.1.60" resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" @@ -18178,21 +18122,6 @@ node-request-interceptor@^0.5.1: debug "^4.1.1" headers-utils "^1.2.0" -nodegit@0.27.0, nodegit@^0.27.0: - version "0.27.0" - resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.27.0.tgz#4e8cc236f60e1c97324a5acff99056fe116a6ebe" - integrity sha512-E9K4gPjWiA0b3Tx5lfWCzG7Cvodi2idl3V5UD2fZrOrHikIfrN7Fc2kWLtMUqqomyoToYJLeIC8IV7xb1CYRLA== - dependencies: - fs-extra "^7.0.0" - got "^10.7.0" - json5 "^2.1.0" - lodash "^4.17.14" - nan "^2.14.0" - node-gyp "^4.0.0" - node-pre-gyp "^0.13.0" - ramda "^0.25.0" - tar-fs "^1.16.3" - nodemon@^2.0.2: version "2.0.6" resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" @@ -18621,20 +18550,6 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw== -openid-client@*, openid-client@^4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" - integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== - dependencies: - base64url "^3.0.1" - got "^11.8.0" - jose "^2.0.2" - lru-cache "^6.0.0" - make-error "^1.3.6" - object-hash "^2.0.1" - oidc-token-hash "^5.0.0" - p-any "^3.0.0" - openid-client@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a" @@ -18649,6 +18564,20 @@ openid-client@^4.1.1: oidc-token-hash "^5.0.0" p-any "^3.0.0" +openid-client@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" + integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== + dependencies: + base64url "^3.0.1" + got "^11.8.0" + jose "^2.0.2" + lru-cache "^6.0.0" + make-error "^1.3.6" + object-hash "^2.0.1" + oidc-token-hash "^5.0.0" + p-any "^3.0.0" + opn@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -18680,7 +18609,7 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ora@*, ora@^4.0.3: +ora@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/ora/-/ora-4.0.4.tgz#e8da697cc5b6a47266655bf68e0fb588d29a545d" integrity sha512-77iGeVU1cIdRhgFzCK8aw1fbtT1B/iZAvWjS+l/o1x0RShMgxHUZaD2yDpWsNCPwXg9z1ZA78Kbdvr8kBmG/Ww== @@ -18784,7 +18713,7 @@ p-each-series@^2.1.0: resolved "https://registry.npmjs.org/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ== -p-event@^4.0.0, p-event@^4.1.0: +p-event@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== @@ -18974,7 +18903,7 @@ packet-reader@1.0.0: resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== -pako@~1.0.5: +pako@^1.0.10, pako@~1.0.5: version "1.0.11" resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -19211,16 +19140,16 @@ passport-onelogin-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" -passport-saml@^1.3.3: - version "1.3.3" - resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" - integrity sha512-54ecY/A6UEsyCehJws6a+J6THvwtYnGl9cnAUxx5DjsuKgZrDs0tSy58K4hCk1XG/LOcdQSF1TR3xlRXgTULhA== +"passport-saml@^1.3.5, <1.4.0": + version "1.3.5" + resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.5.tgz#747f2c8bb8b9fed41e8cd14586df5aa83e8a8996" + integrity sha512-HFamiqgGiMRCbUBm3wx02WYWKb6ojke0WJHrg4QXI8tx35HrTmDiY8MksUXhouJtEkfcRwXjBL2cSEpRQ6+PgQ== dependencies: debug "^3.1.0" passport-strategy "*" q "^1.5.0" xml-crypto "^1.4.0" - xml-encryption "^1.0.0" + xml-encryption "1.2.1" xml2js "0.4.x" xmlbuilder "^11.0.0" xmldom "0.1.x" @@ -20123,6 +20052,11 @@ pretty-hrtime@^1.0.3: resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + prismjs@^1.21.0, prismjs@~1.21.0: version "1.21.0" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" @@ -20314,14 +20248,6 @@ public-encrypt@^4.0.0: randombytes "^2.0.1" safe-buffer "^5.1.2" -pump@^1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" - integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - pump@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" @@ -20467,11 +20393,6 @@ ramda@^0.21.0: resolved "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" integrity sha1-oAGr7bP/YQd9T/HVd9RN536NCjU= -ramda@^0.25.0: - version "0.25.0" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" - integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== - ramda@^0.26, ramda@~0.26.1: version "0.26.1" resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" @@ -21230,7 +21151,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -21917,9 +21838,9 @@ rollup-plugin-dts@1.4.13: "@babel/code-frame" "^7.10.4" rollup-plugin-esbuild@2.6.x: - version "2.6.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.0.tgz#80336399b113a179ccb2af5bdf7c03f061f37146" - integrity sha512-wtyDAX8kNABrBfwkrrG2xLRKRGSWUyMBURS067IRvRMpgJlLRo14WcFl95uGc4iYWfdAx436/z1LMzYqdlY4ig== + version "2.6.1" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.1.tgz#5785532940d49adf1bff5b38e9bd9089262d4e7a" + integrity sha512-hskMEQQ4Vxlyoeg1OWlFTwWHIhpNaw6q+diOT7p9pdkk34m9Mbk3aymS/JbTqLXy/AbJi22iuXrucknKpeczfg== dependencies: "@rollup/pluginutils" "^4.1.0" joycon "^2.2.5" @@ -22080,6 +22001,11 @@ sanitize-html@^1.27.0: srcset "^2.0.1" xtend "^4.0.1" +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -22178,9 +22104,11 @@ semver@7.0.0: integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== semver@7.x, semver@^7.2.1, semver@^7.3.2: - version "7.3.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" - integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: version "6.3.0" @@ -22302,7 +22230,7 @@ setprototypeof@1.2.0: resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: +sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8, sha.js@^2.4.9: version "2.4.11" resolved "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== @@ -22372,7 +22300,7 @@ shelljs@^0.8.2, shelljs@^0.8.4: shellwords@^0.1.1: version "0.1.1" - resolved "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== shortid@^2.2.14: @@ -22413,7 +22341,7 @@ simple-concat@^1.0.0: resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -simple-get@^3.0.3: +simple-get@^3.0.2, simple-get@^3.0.3: version "3.1.0" resolved "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== @@ -23524,16 +23452,6 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar-fs@^1.16.3: - version "1.16.3" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509" - integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw== - dependencies: - chownr "^1.0.1" - mkdirp "^0.5.1" - pump "^1.0.0" - tar-stream "^1.1.2" - tar-fs@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" @@ -23544,31 +23462,7 @@ tar-fs@~2.0.1: pump "^3.0.0" tar-stream "^2.0.0" -tar-stream@^1.1.2: - version "1.6.2" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== - dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" - -tar-stream@^2.0.0: - version "2.1.2" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.2.tgz#6d5ef1a7e5783a95ff70b69b97455a5968dc1325" - integrity sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q== - dependencies: - bl "^4.0.1" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar-stream@^2.1.4: +tar-stream@^2.0.0, tar-stream@^2.1.4: version "2.1.4" resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz#c4fb1a11eb0da29b893a5b25476397ba2d053bfa" integrity sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw== @@ -23907,11 +23801,6 @@ to-arraybuffer@^1.0.0: resolved "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== - to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -23929,11 +23818,6 @@ to-readable-stream@^1.0.0: resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== -to-readable-stream@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz#82880316121bea662cdc226adb30addb50cb06e8" - integrity sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -24234,11 +24118,6 @@ type-detect@4.0.8: resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== -type-fest@^0.10.0: - version "0.10.0" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz#7f06b2b9fbfc581068d1341ffabd0349ceafc642" - integrity sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw== - type-fest@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" @@ -24298,21 +24177,21 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript-json-schema@^0.45.0: - version "0.45.0" - resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.45.0.tgz#2f244e99518e589a442ee5f9c1d2c85a1ec7dc84" - integrity sha512-4MeX0HIODRd+K1/sIbkPryGXG43PVQ9cvIC8gDYml6yBgcsWLjvPMpTNr9VV+bQwpDLncB8Ie4qSenuKUwNZpg== +typescript-json-schema@^0.47.0: + version "0.47.0" + resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.47.0.tgz#84dde5460b127c6774da81bf70b23c7e04857b13" + integrity sha512-A6NVwSOTSsNDHfaqDcDeKwwyXEeKqBHoAr20jcetnYj4e8C6zVFofAVhAuwsBXCRYiWEE/lyHrcxpsSpbIk0Mg== dependencies: "@types/json-schema" "^7.0.6" glob "^7.1.6" json-stable-stringify "^1.0.1" - typescript "^4.1.2" - yargs "^16.1.1" + typescript "^4.1.3" + yargs "^16.2.0" -typescript@^4.0.3, typescript@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" - integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== +typescript@^4.0.3, typescript@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@^0.7.18: version "0.7.21" @@ -24633,6 +24512,14 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -24731,6 +24618,11 @@ utils-merge@1.0.1, utils-merge@1.x.x: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -24742,9 +24634,9 @@ uuid@^7.0.3: integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== uuid@^8.0.0, uuid@^8.2.0, uuid@^8.3.0: - version "8.3.0" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" - integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== v8-compile-cache@^2.0.3: version "2.1.0" @@ -25470,7 +25362,7 @@ xml-crypto@^1.4.0: xmldom "0.1.27" xpath "0.0.27" -xml-encryption@^1.0.0: +xml-encryption@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.1.tgz#e6d18817c4309fd07ca7793cca93c3fd06745baa" integrity sha512-hn5w3l5p2+nGjlmM0CAhMChDzVGhW+M37jH35Z+GJIipXbn9PUlAIRZ6I5Wm7ynlqZjFrMAr83d/CIp9VZJMTA== @@ -25485,6 +25377,14 @@ xml-name-validator@^3.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + xml2js@0.4.x: version "0.4.23" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" @@ -25498,6 +25398,11 @@ xmlbuilder@^11.0.0, xmlbuilder@~11.0.0: resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" + integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" @@ -25563,7 +25468,7 @@ yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: yallist@^4.0.0: version "4.0.0" - resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml-ast-parser@0.0.43, yaml-ast-parser@^0.0.43: @@ -25571,7 +25476,7 @@ yaml-ast-parser@0.0.43, yaml-ast-parser@^0.0.43: resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== -yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: +yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: version "1.10.0" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== @@ -25675,10 +25580,10 @@ yargs@^15.1.0, yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.3, yargs@^16.1.1: - version "16.1.1" - resolved "https://registry.npmjs.org/yargs/-/yargs-16.1.1.tgz#5a4a095bd1ca806b0a50d0c03611d38034d219a1" - integrity sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w== +yargs@^16.0.3, yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== dependencies: cliui "^7.0.2" escalade "^3.1.1" @@ -25770,13 +25675,13 @@ zenscroll@^4.0.2: resolved "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz#e8d5774d1c0738a47bcfa8729f3712e2deddeb25" integrity sha1-6NV3TRwHOKR7z6hynzcS4t7d6yU= -zip-stream@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.0.2.tgz#3a20f1bd7729c2b59fd4efa04df5eb7a5a217d2e" - integrity sha512-TGxB2g+1ur6MHkvM644DuZr8Uzyz0k0OYWtS3YlpfWBEmK4woaC2t3+pozEL3dBfIPmpgmClR5B2QRcMgGt22g== +zip-stream@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.0.4.tgz#3a8f100b73afaa7d1ae9338d910b321dec77ff3a" + integrity sha512-a65wQ3h5gcQ/nQGWV1mSZCEzCML6EK/vyVPcrPNynySP1j3VBbQKh3nhC8CbORb+jfl2vXvh56Ul5odP1bAHqw== dependencies: archiver-utils "^2.1.0" - compress-commons "^4.0.0" + compress-commons "^4.0.2" readable-stream "^3.6.0" zombie@^6.1.4: