diff --git a/.changeset/angry-hounds-bow.md b/.changeset/angry-hounds-bow.md new file mode 100644 index 0000000000..4bbfffd4f1 --- /dev/null +++ b/.changeset/angry-hounds-bow.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Do not throw in `ScmIntegration` `byUrl` for invalid URLs diff --git a/.changeset/beige-ladybugs-sip.md b/.changeset/beige-ladybugs-sip.md new file mode 100644 index 0000000000..f1ef84e233 --- /dev/null +++ b/.changeset/beige-ladybugs-sip.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Improve UX of the Sidebar by adding SidebarScrollWrapper component allowing the user to scroll through Plugins & Shortcuts on smaller screens. Prevent the Sidebar from opening on click on small devices diff --git a/.changeset/breezy-kings-applaud.md b/.changeset/breezy-kings-applaud.md new file mode 100644 index 0000000000..867d135739 --- /dev/null +++ b/.changeset/breezy-kings-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-pagerduty': patch +--- + +Update README diff --git a/.changeset/brown-olives-hang.md b/.changeset/brown-olives-hang.md new file mode 100644 index 0000000000..64ce4e25d5 --- /dev/null +++ b/.changeset/brown-olives-hang.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +add default branch property for publish GitLab, Bitbucket and Azure actions diff --git a/.changeset/eight-pots-remember.md b/.changeset/eight-pots-remember.md new file mode 100644 index 0000000000..34c6d018b5 --- /dev/null +++ b/.changeset/eight-pots-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added filesystem remove/rename built-in actions diff --git a/.changeset/eighty-deers-jam.md b/.changeset/eighty-deers-jam.md new file mode 100644 index 0000000000..df8b84aff1 --- /dev/null +++ b/.changeset/eighty-deers-jam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +More helpful error message when trying to import by folder from non-github diff --git a/.changeset/fair-falcons-scream.md b/.changeset/fair-falcons-scream.md new file mode 100644 index 0000000000..1f2f095c81 --- /dev/null +++ b/.changeset/fair-falcons-scream.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-explore': patch +--- + +- Enhanced core `Button` component to open external links in new tab. +- Replaced the use of `Button` component from material by `core-components` in tools card. diff --git a/.changeset/flat-donkeys-rhyme.md b/.changeset/flat-donkeys-rhyme.md new file mode 100644 index 0000000000..c98f4aedc5 --- /dev/null +++ b/.changeset/flat-donkeys-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Changed the regex to validate names following the Kubernetes validation rule, this allow to be more permissive validating the name of the object in Backstage. diff --git a/.changeset/fresh-elephants-tease.md b/.changeset/fresh-elephants-tease.md new file mode 100644 index 0000000000..5141f96be7 --- /dev/null +++ b/.changeset/fresh-elephants-tease.md @@ -0,0 +1,68 @@ +--- +'@backstage/catalog-model': minor +'@backstage/plugin-catalog-backend': minor +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend': minor +'@backstage/create-app': patch +--- + +Support for `Template` kinds with version `backstage.io/v1alpha1` has now been removed. This means that the old method of running templates with `Preparers`, `Templaters` and `Publishers` has also been removed. If you had any logic in these abstractions, they should now be moved to `actions` instead, and you can find out more about those in the [documentation](https://backstage.io/docs/features/software-templates/writing-custom-actions) + +If you need any help migrating existing templates, there's a [migration guide](https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2). Reach out to us on Discord in the #support channel if you're having problems. + +The `scaffolder-backend` now no longer requires these `Preparers`, `Templaters`, and `Publishers` to be passed in, now all it needs is the `containerRunner`. + +Please update your `packages/backend/src/plugins/scaffolder.ts` like the following + +```diff +- import { +- DockerContainerRunner, +- SingleHostDiscovery, +- } from '@backstage/backend-common'; ++ import { DockerContainerRunner } from '@backstage/backend-common'; + import { CatalogClient } from '@backstage/catalog-client'; +- import { +- CookieCutter, +- CreateReactAppTemplater, +- createRouter, +- Preparers, +- Publishers, +- Templaters, +- } from '@backstage/plugin-scaffolder-backend'; ++ import { createRouter } from '@backstage/plugin-scaffolder-backend'; + import Docker from 'dockerode'; + import { Router } from 'express'; + import type { PluginEnvironment } from '../types'; + + export default async function createPlugin({ + config, + database, + reader, ++ discovery, + }: PluginEnvironment): Promise { + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + +- const cookiecutterTemplater = new CookieCutter({ containerRunner }); +- const craTemplater = new CreateReactAppTemplater({ containerRunner }); +- const templaters = new Templaters(); + +- templaters.register('cookiecutter', cookiecutterTemplater); +- templaters.register('cra', craTemplater); +- +- const preparers = await Preparers.fromConfig(config, { logger }); +- const publishers = await Publishers.fromConfig(config, { logger }); + +- const discovery = SingleHostDiscovery.fromConfig(config); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + + return await createRouter({ +- preparers, +- templaters, +- publishers, ++ containerRunner, + logger, + config, + database, + +``` diff --git a/.changeset/fuzzy-bottles-greet.md b/.changeset/fuzzy-bottles-greet.md deleted file mode 100644 index d54a4300ae..0000000000 --- a/.changeset/fuzzy-bottles-greet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -Reintroduce export of `defaultConfigLoader`. diff --git a/.changeset/gorgeous-timers-cover.md b/.changeset/gorgeous-timers-cover.md new file mode 100644 index 0000000000..de71d740c0 --- /dev/null +++ b/.changeset/gorgeous-timers-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Fix openStack swift publisher encoding issue. Remove utf8 forced encoding on binary files diff --git a/.changeset/great-bears-work.md b/.changeset/great-bears-work.md new file mode 100644 index 0000000000..572cf1538d --- /dev/null +++ b/.changeset/great-bears-work.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added a `context` parameter to validator functions, letting them have access to +the API holder. + +If you have implemented custom validators and use `createScaffolderFieldExtension`, +your `validation` function can now optionally accept a third parameter, +`context: { apiHolder: ApiHolder }`. diff --git a/.changeset/happy-pugs-notice.md b/.changeset/happy-pugs-notice.md new file mode 100644 index 0000000000..4120018da9 --- /dev/null +++ b/.changeset/happy-pugs-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Change catalog page layout to use Grid components to improve responsiveness diff --git a/.changeset/healthy-colts-watch.md b/.changeset/healthy-colts-watch.md new file mode 100644 index 0000000000..2b24621f20 --- /dev/null +++ b/.changeset/healthy-colts-watch.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-test-utils': patch +'@backstage/create-app': patch +'@backstage/plugin-catalog-backend': patch +--- + +bump sqlite3 to 5.0.1 diff --git a/.changeset/honest-cars-glow.md b/.changeset/honest-cars-glow.md new file mode 100644 index 0000000000..3fdc6e335a --- /dev/null +++ b/.changeset/honest-cars-glow.md @@ -0,0 +1,54 @@ +--- +'@backstage/backend-common': patch +--- + +Added a `readUrl` method to the `UrlReader` interface that allows for complex response objects and is intended to replace the `read` method. This new method is currently optional to implement which allows for a soft migration to `readUrl` instead of `read` in the future. + +The main use case for `readUrl` returning an object instead of solely a read buffer is to allow for additional metadata such as ETag, which is a requirement for more efficient catalog processing. + +The `GithubUrlReader` and `GitlabUrlReader` readers fully implement `readUrl`. The other existing readers implement the new method but do not propagate or return ETags. + +While the `readUrl` method is not yet required, it will be in the future, and we already log deprecation warnings when custom `UrlReader` implementations that do not implement `readUrl` are used. We therefore recommend that any existing custom implementations are migrated to implement `readUrl`. + +The old `read` and the new `readUrl` methods can easily be implemented using one another, but we recommend moving the chunk of the implementation to the new `readUrl` method as `read` is being removed, for example this: + +```ts +class CustomUrlReader implements UrlReader { + read(url: string): Promise { + const res = await fetch(url); + + if (!res.ok) { + // error handling ... + } + + return Buffer.from(await res.text()); + } +} +``` + +Can be migrated to something like this: + +```ts +class CustomUrlReader implements UrlReader { + read(url: string): Promise { + const res = await this.readUrl(url); + return res.buffer(); + } + + async readUrl( + url: string, + _options?: ReadUrlOptions, + ): Promise { + const res = await fetch(url); + + if (!res.ok) { + // error handling ... + } + + const buffer = Buffer.from(await res.text()); + return { buffer: async () => buffer }; + } +} +``` + +While there is no usage of the ETag capability yet in the main Backstage packages, you can already add it to your custom implementations. To do so, refer to the documentation of the `readUrl` method and surrounding types, and the existing implementation in `packages/backend-common/src/reading/GithubUrlReader.ts`. diff --git a/.changeset/large-mice-sin.md b/.changeset/large-mice-sin.md new file mode 100644 index 0000000000..77aba8d3c5 --- /dev/null +++ b/.changeset/large-mice-sin.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': minor +--- + +Add extension points to the `LdapOrgReaderProcessor` to make it possible to do more advanced modifications +of the ingested users and groups. diff --git a/.changeset/late-pants-itch.md b/.changeset/late-pants-itch.md new file mode 100644 index 0000000000..7bd3087316 --- /dev/null +++ b/.changeset/late-pants-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Show scroll bar of the sidebar wrapper only on hover diff --git a/.changeset/long-olives-cheer.md b/.changeset/long-olives-cheer.md deleted file mode 100644 index 65c79aeae7..0000000000 --- a/.changeset/long-olives-cheer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Unbreak `.well-known` OIDC routes diff --git a/.changeset/moody-garlics-whisper.md b/.changeset/moody-garlics-whisper.md new file mode 100644 index 0000000000..b9683e4637 --- /dev/null +++ b/.changeset/moody-garlics-whisper.md @@ -0,0 +1,24 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Adding `config: Config` as a required argument to `createBuiltinActions` and downstream methods in order to support configuration of the default git author used for Scaffolder commits. + +The affected methods are: + +- `createBuiltinActions` +- `createPublishGithubAction` +- `createPublishGitlabAction` +- `createPublishBitbucketAction` +- `createPublishAzureAction` + +Call sites to these methods will need to be migrated to include the new `config` argument. See `createRouter` in `plugins/scaffolder-backend/src/service/router.ts` for an example of adding this new argument. + +To configure the default git author, use the `defaultAuthor` key under `scaffolder` in `app-config.yaml`: + +```yaml +scaffolder: + defaultAuthor: + name: Example + email: example@example.com +``` diff --git a/.changeset/nasty-crews-boil.md b/.changeset/nasty-crews-boil.md deleted file mode 100644 index 5b4108c972..0000000000 --- a/.changeset/nasty-crews-boil.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Replaced moment and dayjs with Luxon diff --git a/.changeset/purple-carpets-fold.md b/.changeset/purple-carpets-fold.md deleted file mode 100644 index bdade07931..0000000000 --- a/.changeset/purple-carpets-fold.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/techdocs-common': patch -'@backstage/plugin-register-component': patch -'@backstage/plugin-techdocs-backend': patch ---- - -Update "service catalog" references to "software catalog" diff --git a/.changeset/red-ladybugs-promise.md b/.changeset/red-ladybugs-promise.md new file mode 100644 index 0000000000..dbfbf2bbd4 --- /dev/null +++ b/.changeset/red-ladybugs-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +add support for uiSchema on dependent form fields diff --git a/.changeset/rotten-cooks-try.md b/.changeset/rotten-cooks-try.md new file mode 100644 index 0000000000..ccbd315ebe --- /dev/null +++ b/.changeset/rotten-cooks-try.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-ilert': patch +--- + +chore: bump `@date-io/luxon` from 1.3.13 to 2.10.11 diff --git a/.changeset/rotten-walls-cross.md b/.changeset/rotten-walls-cross.md new file mode 100644 index 0000000000..498c10b203 --- /dev/null +++ b/.changeset/rotten-walls-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Make use of the new `readUrl` method on `UrlReader` from `@backstage/backend-common`. diff --git a/.changeset/rude-pumpkins-hang.md b/.changeset/rude-pumpkins-hang.md deleted file mode 100644 index 9a95b0e455..0000000000 --- a/.changeset/rude-pumpkins-hang.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@backstage/core-components': patch ---- - -Make `ErrorBoundary` display more helpful information about the error that -occurred. - -The `slackChannel` (optional) prop can now be passed as an object on the form -`{ name: string; href?: string; }` in addition to the old string form. If you -are using the error boundary like - -```tsx - - - -``` - -you may like to migrate it to - -```tsx -const support = { - name: '#support', - href: 'https://slack.com/channels/your-channel', -}; - - - - -``` - -Also deprecated the prop `slackChannel` on `TabbedCard` and `InfoCard`, while -adding the prop `errorBoundaryProps` to replace it. diff --git a/.changeset/serious-falcons-greet.md b/.changeset/serious-falcons-greet.md new file mode 100644 index 0000000000..f793ed26d4 --- /dev/null +++ b/.changeset/serious-falcons-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fix downloads from repositories located at bitbucket.org diff --git a/.changeset/shaggy-ties-carry.md b/.changeset/shaggy-ties-carry.md new file mode 100644 index 0000000000..83b7782cd0 --- /dev/null +++ b/.changeset/shaggy-ties-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Handle empty code blocks in markdown files so they don't fail rendering diff --git a/.changeset/shy-garlics-tap.md b/.changeset/shy-garlics-tap.md new file mode 100644 index 0000000000..58c660e864 --- /dev/null +++ b/.changeset/shy-garlics-tap.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Use SidebarScrollWrapper to improve responsiveness of the current sidebar. Change: Wrap a section of SidebarItems with this component to enable scroll for smaller screens. It can also be used in sidebar plugins (see shortcuts plugin for an example). diff --git a/.changeset/silent-horses-fry.md b/.changeset/silent-horses-fry.md new file mode 100644 index 0000000000..2b7c562607 --- /dev/null +++ b/.changeset/silent-horses-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fix error in error panel, and console warnings about DOM nesting pre inside p diff --git a/.changeset/six-birds-approve.md b/.changeset/six-birds-approve.md new file mode 100644 index 0000000000..ce666dd09c --- /dev/null +++ b/.changeset/six-birds-approve.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-org': patch +--- + +Add edit button to Group Profile Card diff --git a/.changeset/forty-students-brake.md b/.changeset/sweet-terms-approve.md similarity index 58% rename from .changeset/forty-students-brake.md rename to .changeset/sweet-terms-approve.md index 8be76e59d2..e8daa06673 100644 --- a/.changeset/forty-students-brake.md +++ b/.changeset/sweet-terms-approve.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': patch --- -Fix `catalog:write` on windows systems +bump azure-devops-node to 10.2.2 diff --git a/.changeset/swift-fishes-taste.md b/.changeset/swift-fishes-taste.md deleted file mode 100644 index 8003e67698..0000000000 --- a/.changeset/swift-fishes-taste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Ensure that emitted relations are deduplicated diff --git a/.changeset/tasty-chairs-flash.md b/.changeset/tasty-chairs-flash.md new file mode 100644 index 0000000000..8be48d92b3 --- /dev/null +++ b/.changeset/tasty-chairs-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Pass through the `idToken` in `Authorization` Header for `listActions` request diff --git a/.changeset/tasty-ghosts-raise.md b/.changeset/tasty-ghosts-raise.md new file mode 100644 index 0000000000..4e9de7043a --- /dev/null +++ b/.changeset/tasty-ghosts-raise.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Change search scheduler from starting indexing in a fixed interval (for example +every 60 seconds), to wait a fixed time between index runs. +This makes sure that no second index process for the same document type is +started when the previous one is still running. diff --git a/.changeset/thirty-turtles-carry.md b/.changeset/thirty-turtles-carry.md deleted file mode 100644 index 96b0495561..0000000000 --- a/.changeset/thirty-turtles-carry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Show better error message when configs defined under auth.providers. are undefined. diff --git a/.changeset/tricky-carrots-lick.md b/.changeset/tricky-carrots-lick.md deleted file mode 100644 index dd5c2d4288..0000000000 --- a/.changeset/tricky-carrots-lick.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Move `LdapOrgReaderProcessor` from `@backstage/plugin-catalog-backend` -to `@backstage/plugin-catalog-backend-module-ldap`. - -The `LdapOrgReaderProcessor` isn't registered by default anymore, if -you want to continue using it you have to register it manually at the catalog -builder: - -1. Add dependency to `@backstage/plugin-catalog-backend-module-ldap` to the `package.json` of your backend. -2. Add the processor to the catalog builder: - -```typescript -// packages/backend/src/plugins/catalog.ts -builder.addProcessor( - LdapOrgReaderProcessor.fromConfig(config, { - logger, - }), -); -``` - -For more configuration details, see the [README of the `@backstage/plugin-catalog-backend-module-ldap` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md). diff --git a/.changeset/twelve-deers-explode.md b/.changeset/twelve-deers-explode.md new file mode 100644 index 0000000000..0d34392eab --- /dev/null +++ b/.changeset/twelve-deers-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Export the `fetchContents` from scaffolder-backend diff --git a/.changeset/two-pots-joke.md b/.changeset/two-pots-joke.md deleted file mode 100644 index 7a97777524..0000000000 --- a/.changeset/two-pots-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Get rid of flex console warning for IconLink diff --git a/.changeset/violet-maps-smell.md b/.changeset/violet-maps-smell.md new file mode 100644 index 0000000000..fdb4c6b46d --- /dev/null +++ b/.changeset/violet-maps-smell.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +add defaultBranch property for publish GitHub action diff --git a/.changeset/wet-queens-deny.md b/.changeset/wet-queens-deny.md new file mode 100644 index 0000000000..1e446c619c --- /dev/null +++ b/.changeset/wet-queens-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +removing mandatory of protection for the default branch, that could be handled by the GitHub automation in async manner, thus throwing floating errors diff --git a/.changeset/young-pigs-beg.md b/.changeset/young-pigs-beg.md new file mode 100644 index 0000000000..bfdf726911 --- /dev/null +++ b/.changeset/young-pigs-beg.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Add custom styles to scroll bar of the sidebar wrapper to fix flaky behaviour diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 846e050c49..f6826cbefe 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -137,6 +137,7 @@ maintainership makefile md memcache +memoized microservice microservices microsite @@ -281,6 +282,7 @@ unregistration untracked upvote url +URLs utils validator validators diff --git a/ADOPTERS.md b/ADOPTERS.md index 94a470e1a7..1dcd4a774f 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,7 +1,7 @@ | Organization | Contact | Description of Use | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [bol.com](https://www.bol.com) | [@sagacity](https://github.com/sagacity) | Initial work being done to unify platform tooling. | | [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | | [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | | [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | @@ -34,3 +34,6 @@ | [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | | [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | | [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | +| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. | +| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. | diff --git a/README.md b/README.md index f8b54ad200..895106c967 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ # [Backstage](https://backstage.io) +_During the month of July the majority of the maintainers will be on summer vacation 🏖️ Development will continue as usual, but expect a slower pace for discussions and PR reviews. Why not take this opportunity to [build a plugin](https://backstage.io/docs/plugins/)?_ + [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects) [![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22) diff --git a/app-config.yaml b/app-config.yaml index 72b292f144..b32f7ea5e9 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -253,6 +253,10 @@ catalog: target: ../catalog-model/examples/acme-corp.yaml scaffolder: + # Use to customize default commit author info used when new components are created + # defaultAuthor: + # name: Scaffolder + # email: scaffolder@backstage.io github: token: ${GITHUB_TOKEN} visibility: public # or 'internal' or 'private' diff --git a/cypress/src/integration/catalog.ts b/cypress/src/integration/catalog.ts index 1db2eb8a13..20dcdd9b8a 100644 --- a/cypress/src/integration/catalog.ts +++ b/cypress/src/integration/catalog.ts @@ -23,7 +23,7 @@ describe('Catalog', () => { cy.visit('/catalog'); - cy.contains('Owned (8)').should('be.visible'); + cy.contains('Owned (10)').should('be.visible'); }); }); }); diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts index 3e3a10aca2..4703c18881 100644 --- a/cypress/src/integration/integrations.ts +++ b/cypress/src/integration/integrations.ts @@ -27,8 +27,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'github-repo'); cy.get('table').should('contain', 'github-repo-nested'); }); @@ -52,8 +54,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'gitlab-repo'); cy.get('table').should('contain', 'gitlab-repo-nested'); }); @@ -67,8 +71,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'bitbucket-repo'); cy.get('table').should('contain', 'bitbucket-repo-nested'); }); diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 095a4989d8..e542b02ebf 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -59,16 +59,17 @@ Once the host build is complete, we are ready to build our image. The following FROM node:14-buster-slim WORKDIR /app - # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +COPY yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" # Then copy the rest of the backend bundle, along with any other files we might want. -ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ +COPY packages/backend/dist/bundle.tar.gz app-config.yaml ./ +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz CMD ["node", "packages/backend", "--config", "app-config.yaml"] ``` diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 6c64e83a76..3f3c92d0de 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -78,11 +78,6 @@ the repository. The archive does not have any git history attached to it. Also it is a compressed file. Hence the file size is significantly smaller than how much data git clone has to transfer. -Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 -minutes which means they will not be re-built if new changes are made within 30 -minutes. This cache invalidation will be replaced by commit timestamp based -implementation very soon. - ## How to use a custom TechDocs home page? ### 1st way: TechDocsCustomHome with a custom configuration diff --git a/docs/features/techdocs/troubleshooting.md b/docs/features/techdocs/troubleshooting.md index e6efefc578..d8b413be5c 100644 --- a/docs/features/techdocs/troubleshooting.md +++ b/docs/features/techdocs/troubleshooting.md @@ -55,3 +55,20 @@ INFO - Start watching changes [I 210115 19:00:45 handlers:64] Start detecting changes INFO - Start detecting changes ``` + +## PlantUML with `svg_object` doesn't render + +The [plantuml-markdown](https://pypi.org/project/plantuml-markdown/) MkDocs +plugin available in +[`mkdocs-techdocs-core`](https://github.com/backstage/mkdocs-techdocs-core) +supports different formats for rendering diagrams. TechDocs does however not +support all of them. + +The `svg_object` format renders a diagram as an HTML `` tag but this is +not allowed as it enables bad actors to inject malicious content into +documentation pages. See +[CVE-2021-32661](https://github.com/advisories/GHSA-gg96-f8wr-p89f) for more +details. + +Instead use `svg_inline` which renders as an `` tag and provides the same +benefits as `svg_object`. diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 836601971c..d1438aba8b 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -122,8 +122,8 @@ The DN under which users are stored, e.g. #### users.options The search options to use when sending the query to the server, when reading all -users. All of the options are shown below, with their default values, but they -are all optional. +users. All the options are shown below, with their default values, but they are +all optional. ```yaml options: @@ -158,8 +158,8 @@ set: Mappings from well known entity fields, to LDAP attribute names. This is where you are able to define how to interpret the attributes of each LDAP result item, -and to move them into the corresponding entity fields. All of the options are -shown below, with their default values, but they are all optional. +and to move them into the corresponding entity fields. All the options are shown +below, with their default values, but they are all optional. If you leave out an optional mapping, it will still be copied using that default value. For example, even if you do not put in the field `displayName` in your @@ -204,8 +204,8 @@ The DN under which groups are stored, e.g. #### groups.options The search options to use when sending the query to the server, when reading all -groups. All of the options are shown below, with their default values, but they -are all optional. +groups. All the options are shown below, with their default values, but they are +all optional. ```yaml options: @@ -282,3 +282,35 @@ map: # the spec.children field of the entity. members: member ``` + +## Customize the Processor + +In case you want to customize the ingested entities, the +`LdapOrgReaderProcessor` allows to pass transformers for users and groups. + +1. Create a transformer: + +```ts +export async function myGroupTransformer( + vendor: LdapVendor, + config: GroupConfig, + group: SearchEntry, +): Promise { + // Transformations may change namespace, change entity naming pattern, fill + // profile with more or other details... + + // Create the group entity on your own, or wrap the default transformer + return await defaultGroupTransformer(vendor, config, group); +} +``` + +2. Configure the processor with the transformer: + +```ts +builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(config, { + logger, + groupTransformer: myGroupTransformer, + }), +); +``` diff --git a/docs/plugins/url-reader.md b/docs/plugins/url-reader.md new file mode 100644 index 0000000000..41aba98a66 --- /dev/null +++ b/docs/plugins/url-reader.md @@ -0,0 +1,274 @@ +--- +id: url-reader +title: URL Reader +sidebar_label: URL Reader +# prettier-ignore +description: URL Reader is a backend core API responsible for reading files from external locations. +--- + +## Concept + +Some of the core plugins of Backstage have to read files from an external +location. [Software Catalog](../features/software-catalog/index.md) has to read +the [`catalog-info.yaml`](../features/software-catalog/descriptor-format.md) +entity descriptor files to register and track an entity. +[Software Templates](../features/software-templates/index.md) have to download +the template skeleton files before creating a new component. +[TechDocs](../features/techdocs/README.md) has to download the markdown source +files before generating a documentation site. + +Since, the requirement for reading files is so essential for Backstage plugins, +the +[`@backstage/backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common) +package provides a dedicated API for reading from such URL based remote +locations like GitHub, GitLab, Bitbucket, Google Cloud Storage, etc. This is +commonly referred to as "URL Reader". It takes care of making authenticated +requests to the remote host so that private files can be read securely. If users +have [GitHub App based authentication](github-apps.md) set up, URL Reader even +refreshes the token, to avoid reaching the GitHub API rate limit. + +As a result, plugin authors do not have to worry about any of these problems +when trying to read files. + +## Interface + +When the Backstage backend starts, a new instance of URL Reader is created. You +can see this in the index file of your Backstage backend +i.e.`packages/backend/src/index.ts`. +[Example](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57) + +```ts +// File: packages/backend/src/index.ts + +import { URLReaders } from '@backstage/backend-common'; + +function makeCreateEnv(config: Config) { + // .... + const reader = UrlReaders.default({ logger, config }); + // +} +``` + +This instance contains all +[the default URL Reader providers](https://github.com/backstage/backstage/blob/master/packages/backend-common/src/reading/UrlReaders.ts) +in the backend-common package including GitHub, GitLab, Bitbucket, Azure, Google +GCS. As the need arises, more URL Readers are being written to support different +providers. + +The generic interface of a URL Reader instance looks like this. + +```ts +export type UrlReader = { + /* Used to read a single file and return its content. */ + read(url: string): Promise; + /** + * A replacement for the read method that supports options and complex responses. + * + * Use this whenever it is available, as the read method will be deprecated and + * eventually removed in the future. + */ + readUrl?(url: string, options?: ReadUrlOptions): Promise; + + /* Used to read a file tree and download as a directory. */ + readTree(url: string, options?: ReadTreeOptions): Promise; + /* Used to search a file in a tree. */ + search(url: string, options?: SearchOptions): Promise; +}; +``` + +## Using a URL Reader inside a plugin + +The `reader` instance is available in the backend plugin environment and passed +on to all the backend plugins. You can see an +[example](https://github.com/backstage/backstage/blob/b0be185369ebaad22255b7cdf18535d1d4ffd0e7/packages/backend/src/plugins/techdocs.ts#L31). +When any of the methods on this instance is called with a URL, URL Reader +extracts the host for that URL (e.g. `github.com`, `ghe.mycompany.com`, etc.). +Using the +[`@backstage/integration`](https://github.com/backstage/backstage/tree/master/packages/integration) +package, it looks inside the +[`integrations:`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/app-config.yaml#L134-L158) +config of the `app-config.yaml` to find out how to work with the host based on +the configs provided like authentication token, API base URL, etc. + +Make sure your plugin-specific backend file at +`packages/backend/src/plugins/.ts` is forwarding the `reader` instance +passed on as the `PluginEnvironment` to the actual plugin's `createRouter` +function. See how this is done in +[Catalog](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/catalog.ts#L25-L27) +and +[TechDocs](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/techdocs.ts#L31-L36) +backend plugins. + +Once the reader instance is available inside the plugin, one of its methods can +directly be used with a URL. Some example usages - + +- [`read`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts#L24-L33) - + Catalog using the `read` method to read the CODEOWNERS file in a repository. +- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/helpers.ts#L198-L220) - + TechDocs using the `readTree` method to download markdown files in order to + generate the documentation site. +- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/stages/prepare/url.ts#L33-L54) - + TechDocs using `NotModifiedError` to maintain cache and speed up and limit the + number of requests. +- [`search`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts#L88-L108) - + Catalog using the `search` method to find files for a location URL containing + a glob pattern. + +## Writing a new URL Reader + +If the available URL Readers are not sufficient for your use case and you want +to add a new URL Reader for any other provider, you are most welcome to +contribute one! + +Feel free to use the +[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts) +as a source of inspiration. + +### 1. Add an integration + +The provider for your new URL Reader can also be called an "integration" in +Backstage. The `integrations:` section of your Backstage `app-config.yaml` +config file is supposed to be the place where a Backstage integrator defines the +host URL for the integration, authentication details and other integration +related configurations. + +The `@backstage/integration` package is where most of the integration specific +code lives, so that it is shareable across Backstage. Functions like "read the +integrations config and process it", "construct headers for authenticated +requests to the host" or "convert a plain file URL into its API URL for +downloading the file" would live in this package. + +### 2. Create the URL Reader + +Create a new class which implements the +[`UrlReader` type](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/types.ts#L21-L28) +inside `@backstage/backend-common`. Create and export a static `factory` method +which reads the integration config and returns a map of host URLs the new reader +should be used for. See the +[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts#L50-L63) +for example. + +### 3. Implement the methods + +We want to make sure all URL Readers behave in the same way. Hence if possible, +all the methods of the `UrlReader` interface should be implemented. However it +is okay to start by implementing just one of them and create issues for the +remaining. + +#### read + +NOTE: Use `readUrl` instead of `read`. + +`read` method expects a user-friendly URL, something which can be copied from +the browser naturally when a person is browsing the provider in their browser. + +- ✅ Valid URL : + `https://github.com/backstage/backstage/blob/master/ADOPTERS.md` +- ❌ Not a valid URL : + `https://raw.githubusercontent.com/backstage/backstage/master/ADOPTERS.md` +- ❌ Not a valid URL : `https://github.com/backstage/backstage/ADOPTERS.md` + +Upon receiving the URL, `read` converts the user-friendly URL into an API URL +which can be used to request the provider's API. + +`read` then makes an authenticated request to the provider API and returns the +file's content. + +#### readUrl + +`readUrl` is a new interface that allows complex response objects and is +intended to replace the `read` method. This new method is currently optional to +implement which allows for a soft migration to `readUrl` instead of `read` in +the future. + +#### readTree + +`readTree` method also expects user-friendly URLs similar to `read` but the URL +should point to a tree (could be the root of a repository or even a +sub-directory). + +- ✅ Valid URL : `https://github.com/backstage/backstage` +- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master` +- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/docs` + +Using the provider's API documentation, find out an API endpoint which can be +used to download either a zip or a tarball. You can download the entire tree +(e.g. a repository) and filter out in case the user is expecting only a +sub-tree. But some APIs are smart enough to accept a path and return only a +sub-tree in the downloaded archive. + +#### search + +`search` method expects a glob pattern of a URL and returns a list of files +matching the query. + +- ✅ Valid URL : + `https://github.com/backstage/backstage/blob/master/**/catalog-info.yaml` +- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/**/*.md` +- ✅ Valid URL : + `https://github.com/backstage/backstage/blob/master/*/package.json` +- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/READM` + +The core logic of `readTree` can be used here to extract all the files inside +the tree and return the files matching the pattern in the `url`. + +### 4. Add to available URL Readers + +There are two ways to make your new URL Reader available for use. + +You can choose to make it open source, by updating the +[`default` factory](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/UrlReaders.ts#L62-L81) +method of URL Readers. + +But for something internal which you don't want to make open source, you can +update your `packages/backend/src/index.ts` file and update how the `reader` +instance is created. + +```ts +// File: packages/backend/src/index.ts +const reader = UrlReaders.default({ + logger: root, + config, + // This is where your internal URL Readers would go. + factories: [myCustomReader.factory], +}); +``` + +### 5. Caching + +All of the methods above support an ETag based caching. If the method is called +without an `etag`, the response contains an ETag of the resource (should ideally +forward the ETag returned by the provider). If the method is called with an +`etag`, it first compares the ETag and returns a `NotModifiedError` in case the +resource has not been modified. This approach is very similar to the actual +[ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) and +[If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) +HTTP headers. + +### 6. Debugging + +When debugging one of the URL Readers, you can straightforward use the +[`reader` instance created](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57) +when the backend starts and call one of the methods with your debugging URL. + +```ts +// File: packages/backend/src/index.ts + +async function main() { + // ... + const createEnv = makeCreateEnv(config); + + const testReader = createEnv('test-url-reader').reader; + const response = await testReader.readUrl( + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + ); + console.log((await response.buffer()).toString()); + // ... +} +``` + +This will be run every time you restart the backend. Note that after any change +in the URL Reader code, you need to kill the backend and restart, since the +`reader` instance is memoized and does not update on hot module reloading. Also, +there are a lot of unit tests written for the URL Readers, which you can make +use of. diff --git a/microsite/blog/assets/21-06-24/backstage-search-platform.png b/microsite/blog/assets/21-06-24/backstage-search-platform.png index bb7e828ea9..b83b3aa0f4 100644 Binary files a/microsite/blog/assets/21-06-24/backstage-search-platform.png and b/microsite/blog/assets/21-06-24/backstage-search-platform.png differ diff --git a/microsite/blog/assets/21-06-24/jobs-to-be-done.png b/microsite/blog/assets/21-06-24/jobs-to-be-done.png index da86befcc2..74585d0945 100644 Binary files a/microsite/blog/assets/21-06-24/jobs-to-be-done.png and b/microsite/blog/assets/21-06-24/jobs-to-be-done.png differ diff --git a/microsite/blog/assets/21-06-24/search-results.png b/microsite/blog/assets/21-06-24/search-results.png index 041f86488c..5524d89d7e 100644 Binary files a/microsite/blog/assets/21-06-24/search-results.png and b/microsite/blog/assets/21-06-24/search-results.png differ diff --git a/microsite/data/plugins/harbor.yaml b/microsite/data/plugins/harbor.yaml new file mode 100644 index 0000000000..a851f99545 --- /dev/null +++ b/microsite/data/plugins/harbor.yaml @@ -0,0 +1,13 @@ +--- +title: Harbor +author: BESTSELLER +authorUrl: bestsellerit.com +category: Discovery +description: This plugin will show you information about docker images within harbor. +documentation: https://github.com/BESTSELLER/backstage-plugin-harbor/blob/master/README.md +iconUrl: https://bestsellerit.com/img/terraform-harbor/goharbor.jpeg +npmPackageName: '@bestsellerit/backstage-plugin-harbor' +tags: + - goharbor + - harbor + - docker diff --git a/microsite/data/plugins/scaffolder-backend-module-rails.yaml b/microsite/data/plugins/scaffolder-backend-module-rails.yaml new file mode 100644 index 0000000000..5f5a29cf3c --- /dev/null +++ b/microsite/data/plugins/scaffolder-backend-module-rails.yaml @@ -0,0 +1,9 @@ +--- +title: Scaffolder Backend Module Rails +author: Rogerio Angeliski +authorUrl: https://angeliski.com.br/ +category: Scaffolder +description: Here you can find all Rails related features to improve your scaffolder. +documentation: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend-module-rails/README.md +iconUrl: img/rails-icon.png +npmPackageName: '@backstage/plugin-scaffolder-backend-module-rails' diff --git a/microsite/package.json b/microsite/package.json index 1894e37d84..eee549fb70 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -19,7 +19,7 @@ "@spotify/prettier-config": "^10.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.3.1", + "prettier": "^2.3.2", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 0231d8f692..5999524073 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -166,7 +166,8 @@ "plugins/proxying", "plugins/backend-plugin", "plugins/call-existing-api", - "plugins/github-apps" + "plugins/github-apps", + "plugins/url-reader" ] }, { diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 6404b25251..2c8b46ff00 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -137,6 +137,20 @@ td { color: $navigatorItemTextColor; } +/* search */ +[aria-expanded='true'] ~ .aa-without-1 { + display: block !important; +} + +[aria-expanded='true'] ~ .aa-without-1 .aa-dataset-1:after { + content: 'No results'; +} + +.navSearchWrapper { + /* Prevents the search box from detaching from its icon when pushed down */ + justify-content: flex-start; +} + /* header */ .fixedHeaderContainer { position: fixed; @@ -146,6 +160,12 @@ td { box-shadow: 3px 3px 8px rgba(38, 38, 38, 0.25); color: #fff; } + +.wrapper.headerWrapper { + /* Keep the header stretched across the screen on the mid breakpoint, since we need a bit more space */ + max-width: 1400px; +} + @media only screen and (min-width: 1024px) { .fixedHeaderContainer { height: 66px; diff --git a/microsite/static/img/rails-icon.png b/microsite/static/img/rails-icon.png new file mode 100644 index 0000000000..f7b8b69bd9 Binary files /dev/null and b/microsite/static/img/rails-icon.png differ diff --git a/microsite/yarn.lock b/microsite/yarn.lock index a89235bf98..1da9288808 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -5207,10 +5207,10 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.3.1: - version "2.3.1" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6" - integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA== +prettier@^2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d" + integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ== prismjs@^1.22.0: version "1.23.0" diff --git a/mkdocs.yml b/mkdocs.yml index c3d77e179f..4d984e63e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -112,6 +112,7 @@ nav: - Backend plugin: 'plugins/backend-plugin.md' - Call existing API: 'plugins/call-existing-api.md' - GitHub Apps for Backend Authentication: 'plugins/github-apps.md' + - URL Reader: 'plugins/url-reader.md' - Testing: - Testing with Jest: 'plugins/testing.md' - Publishing: diff --git a/package.json b/package.json index c0887d7f05..09dc55617b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "command-exists": "^1.2.9", "concurrently": "^6.0.0", "eslint-plugin-notice": "^0.9.10", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "husky": "^6.0.0", "lerna": "^4.0.0", "lint-staged": "^10.1.0", diff --git a/packages/app/package.json b/packages/app/package.json index f820575272..a7584eb248 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -44,10 +44,10 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.5.3", - "@roadiehq/backstage-plugin-buildkite": "^1.0.3", - "@roadiehq/backstage-plugin-github-insights": "^1.1.11", - "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.5", - "@roadiehq/backstage-plugin-travis-ci": "^1.0.0", + "@roadiehq/backstage-plugin-buildkite": "^1.0.4", + "@roadiehq/backstage-plugin-github-insights": "^1.1.15", + "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.8", + "@roadiehq/backstage-plugin-travis-ci": "^1.0.4", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 09ee571cf6..48141e4f53 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -39,6 +39,7 @@ import { SidebarItem, SidebarDivider, SidebarSpace, + SidebarScrollWrapper, } from '@backstage/core-components'; const useSidebarLogoStyles = makeStyles({ @@ -88,10 +89,12 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( {/* End global nav */} - - - - + + + + + + diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index c37137f524..8010c14aec 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/backend-common +## 0.8.4 + +### Patch Changes + +- 88d742eb8: Download archives as compressed tar files for GitLab to fix the `readTree` bug in TODO Plugin. +- ab5cc376f: Add new `isChildPath` and `resolveSafeChildPath` exports +- Updated dependencies + - @backstage/cli-common@0.1.2 + - @backstage/integration@0.5.7 + ## 0.8.3 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index fcddb8dda5..22eac06ed2 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -16,9 +16,10 @@ import { GithubCredentialsProvider } from '@backstage/integration'; import { GitHubIntegration } from '@backstage/integration'; import { GitLabIntegration } from '@backstage/integration'; import * as http from 'http'; +import { isChildPath } from '@backstage/cli-common'; import { JsonValue } from '@backstage/config'; import { Knex } from 'knex'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { MergeResult } from 'isomorphic-git'; import { PushResult } from 'isomorphic-git'; import { Readable } from 'stream'; @@ -41,6 +42,8 @@ export class AzureUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) + readUrl(url: string, _options?: ReadUrlOptions): Promise; + // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) toString(): string; @@ -58,6 +61,8 @@ export class BitbucketUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) + readUrl(url: string, _options?: ReadUrlOptions): Promise; + // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) toString(): string; @@ -124,7 +129,7 @@ export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler // @public (undocumented) export type ErrorHandlerOptions = { showStackTraces?: boolean; - logger?: Logger; + logger?: Logger_2; logClientErrors?: boolean; }; @@ -180,11 +185,12 @@ export class Git { static fromAuth: ({ username, password, logger, }: { username?: string | undefined; password?: string | undefined; - logger?: Logger | undefined; + logger?: Logger_2 | undefined; }) => Git; // (undocumented) - init({ dir }: { + init({ dir, defaultBranch, }: { dir: string; + defaultBranch?: string; }): Promise; // (undocumented) merge({ dir, theirs, ours, author, committer, }: { @@ -230,6 +236,8 @@ export class GithubUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) + readUrl(url: string, options?: ReadUrlOptions): Promise; + // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) toString(): string; @@ -247,11 +255,15 @@ export class GitlabUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) + readUrl(url: string, options?: ReadUrlOptions): Promise; + // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) toString(): string; } +export { isChildPath } + // @public export function loadBackendConfig(options: Options): Promise; @@ -274,7 +286,7 @@ export type PluginEndpointDiscovery = { getExternalBaseUrl(pluginId: string): Promise; }; -// @public (undocumented) +// @public export type ReadTreeResponse = { files(): Promise; archive(): Promise; @@ -289,11 +301,14 @@ export type ReadTreeResponseFile = { }; // @public -export function requestLoggingHandler(logger?: Logger): RequestHandler; +export function requestLoggingHandler(logger?: Logger_2): RequestHandler; // @public export function resolvePackagePath(name: string, ...paths: string[]): string; +// @public +export function resolveSafeChildPath(base: string, path: string): string; + // @public (undocumented) export type RunContainerOptions = { imageName: string; @@ -322,7 +337,7 @@ export type ServiceBuilder = { loadConfig(config: ConfigReader): ServiceBuilder; setPort(port: number): ServiceBuilder; setHost(host: string): ServiceBuilder; - setLogger(logger: Logger): ServiceBuilder; + setLogger(logger: Logger_2): ServiceBuilder; enableCors(options: cors.CorsOptions): ServiceBuilder; setHttpsSettings(settings: HttpsSettings): ServiceBuilder; addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; @@ -360,6 +375,7 @@ export interface StatusCheckHandlerOptions { // @public export type UrlReader = { read(url: string): Promise; + readUrl?(url: string, options?: ReadUrlOptions): Promise; readTree(url: string, options?: ReadTreeOptions): Promise; search(url: string, options?: SearchOptions): Promise; }; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index fdab4b4504..fdbea23bd8 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.8.3", + "version": "0.8.4", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.1", + "@backstage/cli-common": "^0.1.2", "@backstage/config": "^0.1.5", "@backstage/config-loader": "^0.6.4", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@google-cloud/storage": "^5.8.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", @@ -47,7 +47,7 @@ "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.1", + "fs-extra": "^10.0.0", "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", @@ -76,7 +76,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.7.1", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.12", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts index efe8e027c8..ffe99fe762 100644 --- a/packages/backend-common/src/paths.ts +++ b/packages/backend-common/src/paths.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isChildPath } from '@backstage/cli-common'; +import { NotAllowedError } from '@backstage/errors'; import { resolve as resolvePath } from 'path'; /** @@ -32,3 +34,27 @@ export function resolvePackagePath(name: string, ...paths: string[]) { return resolvePath(req.resolve(`${name}/package.json`), '..', ...paths); } + +/** + * Resolves a target path from a base path while guaranteeing that the result is + * a path that point to or within the base path. This is useful for resolving + * paths from user input, as it otherwise opens up for vulnerabilities. + * + * @param base The base directory to resolve the path from. + * @param path The target path, relative or absolute + * @returns A path that is guaranteed to point to or within the base path. + */ +export function resolveSafeChildPath(base: string, path: string): string { + const targetPath = resolvePath(base, path); + + if (!isChildPath(base, targetPath)) { + throw new NotAllowedError( + 'Relative path is not allowed to refer to a directory outside its parent', + ); + } + + return targetPath; +} + +// Re-export isChildPath so that backend packages don't need to depend on cli-common +export { isChildPath }; diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index fe541538ea..bc22854d4e 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -36,6 +36,8 @@ import { SearchOptions, SearchResponse, UrlReader, + ReadUrlOptions, + ReadUrlResponse, } from './types'; export class AzureUrlReader implements UrlReader { @@ -78,6 +80,15 @@ export class AzureUrlReader implements UrlReader { throw new Error(message); } + async readUrl( + url: string, + _options?: ReadUrlOptions, + ): Promise { + // TODO etag is not implemented yet. + const buffer = await this.read(url); + return { buffer: async () => buffer }; + } + async readTree( url: string, options?: ReadTreeOptions, diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index d8bcde2397..b032322627 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -74,7 +74,24 @@ describe('BitbucketUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); - describe('implementation', () => { + describe('readUrl', () => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml', + (_, res, ctx) => res(ctx.status(200), ctx.body('foo')), + ), + ); + + it('should be able to readUrl', async () => { + const result = await bitbucketProcessor.readUrl( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + ); + const buffer = await result.buffer(); + expect(buffer.toString()).toBe('foo'); + }); + }); + + describe('read', () => { it('rejects unknown targets', async () => { await expect( bitbucketProcessor.read('https://not.bitbucket.com/apa'), @@ -119,14 +136,14 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://bitbucket.org/backstage/mock/get/master.tgz', + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock-12ab34cd56ef.tgz', + 'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz', ), ctx.body(repoBuffer), ), @@ -142,7 +159,7 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', (_, res, ctx) => res( ctx.status(200), @@ -304,14 +321,14 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://bitbucket.org/backstage/mock/get/master.tgz', + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), ctx.set( 'content-disposition', - 'attachment; filename=backstage-mock-12ab34cd56ef.tgz', + 'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz', ), ctx.body(repoBuffer), ), @@ -366,7 +383,7 @@ describe('BitbucketUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&prefix=mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index c4dc6e135f..2849bc3cd9 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -36,6 +36,8 @@ import { SearchOptions, SearchResponse, UrlReader, + ReadUrlResponse, + ReadUrlOptions, } from './types'; /** @@ -99,6 +101,15 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } + async readUrl( + url: string, + _options?: ReadUrlOptions, + ): Promise { + // TODO etag is not implemented yet. + const buffer = await this.read(url); + return { buffer: async () => buffer }; + } + async readTree( url: string, options?: ReadTreeOptions, diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 4c03ea904d..30468158aa 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -19,6 +19,8 @@ import { NotFoundError } from '@backstage/errors'; import { ReaderFactory, ReadTreeResponse, + ReadUrlOptions, + ReadUrlResponse, SearchResponse, UrlReader, } from './types'; @@ -73,6 +75,15 @@ export class FetchUrlReader implements UrlReader { throw new Error(message); } + async readUrl( + url: string, + _options?: ReadUrlOptions, + ): Promise { + // TODO etag is not implemented yet. + const buffer = await this.read(url); + return { buffer: async () => buffer }; + } + async readTree(): Promise { throw new Error('FetchUrlReader does not implement readTree'); } diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 787ef82f60..c8002cd3ce 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -118,7 +118,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/?ref=main', + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, @@ -141,6 +141,113 @@ describe('GithubUrlReader', () => { }); }); + /* + * readUrl + */ + describe('readUrl', () => { + it('should use the headers from the credentials provider to the fetch request when doing readUrl', async () => { + expect.assertions(2); + + const mockHeaders = { + Authorization: 'bearer blah', + otherheader: 'something', + }; + + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: mockHeaders, + }); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.body('foo'), + ); + }, + ), + ); + + await gheProcessor.readUrl( + 'https://github.com/backstage/mock/tree/blob/main', + ); + }); + + it('should throw NotModified if GitHub responds with 304', async () => { + expect.assertions(4); + + const mockHeaders = { + Authorization: 'bearer blah', + otherheader: 'something', + }; + + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: mockHeaders, + }); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + expect(req.headers.get('if-none-match')).toBe('foo'); + return res( + ctx.status(304), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.body('foo'), + ); + }, + ), + ); + + await expect( + gheProcessor.readUrl( + 'https://github.com/backstage/mock/tree/blob/main', + { etag: 'foo' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + + it('should return etag from the response', async () => { + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: { + Authorization: 'bearer blah', + }, + }); + + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Etag', 'foo'), + ctx.body('bar'), + ); + }, + ), + ); + + const response = await gheProcessor.readUrl( + 'https://github.com/backstage/mock/tree/blob/main', + ); + expect(response.etag).toBe('foo'); + }); + }); + /* * readTree */ diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 70bc9601df..bccddc6838 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -35,6 +35,8 @@ import { SearchResponse, SearchResponseFile, UrlReader, + ReadUrlOptions, + ReadUrlResponse, } from './types'; export type GhRepoResponse = RestEndpointMethodTypes['repos']['get']['response']['data']; @@ -77,6 +79,14 @@ export class GithubUrlReader implements UrlReader { } async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { const ghUrl = getGitHubFileFetchUrl(url, this.integration.config); const { headers } = await this.deps.credentialsProvider.getCredentials({ url, @@ -86,6 +96,7 @@ export class GithubUrlReader implements UrlReader { response = await fetch(ghUrl.toString(), { headers: { ...headers, + ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, }); @@ -93,8 +104,15 @@ export class GithubUrlReader implements UrlReader { throw new Error(`Unable to read ${url}, ${e}`); } + if (response.status === 304) { + throw new NotModifiedError(); + } + if (response.ok) { - return Buffer.from(await response.text()); + return { + buffer: async () => Buffer.from(await response.text()), + etag: response.headers.get('ETag') ?? undefined, + }; } const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index a76b898aaf..945444d8ed 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -79,7 +79,7 @@ describe('GitlabUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); - describe('implementation', () => { + describe('read', () => { beforeEach(() => { worker.use( rest.get('*/api/v4/projects/:name', (_, res, ctx) => @@ -180,9 +180,56 @@ describe('GitlabUrlReader', () => { }); }); + describe('readUrl', () => { + const [{ reader }] = GitlabUrlReader.factory({ + config: new ConfigReader({}), + logger, + treeResponseFactory, + }); + + it('should throw NotModified on HTTP 304', async () => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + rest.get('*', (req, res, ctx) => { + expect(req.headers.get('If-None-Match')).toBe('999'); + return res(ctx.status(304)); + }), + ); + + await expect( + reader.readUrl!( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + { + etag: '999', + }, + ), + ).rejects.toThrow(NotModifiedError); + }); + + it('should return etag in response', async () => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + rest.get('*', (_req, res, ctx) => { + return res(ctx.status(200), ctx.set('ETag', '999'), ctx.body('foo')); + }), + ); + + const result = await reader.readUrl!( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + ); + expect(result.etag).toBe('999'); + const content = await result.buffer(); + expect(content.toString()).toBe('foo'); + }); + }); + describe('readTree', () => { const archiveBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.tar.gz'), ); const projectGitlabApiResponse = { @@ -205,7 +252,7 @@ describe('GitlabUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), @@ -283,7 +330,7 @@ describe('GitlabUrlReader', () => { }, ), rest.get( - 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), @@ -306,8 +353,8 @@ describe('GitlabUrlReader', () => { const files = await response.files(); expect(files.length).toBe(2); - const mkDocsFile = await files[0].content(); - const indexMarkdownFile = await files[1].content(); + const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); @@ -318,7 +365,7 @@ describe('GitlabUrlReader', () => { 'https://gitlab.com/backstage/mock', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), @@ -331,7 +378,7 @@ describe('GitlabUrlReader', () => { it('returns the wanted files from hosted gitlab', async () => { worker.use( rest.get( - 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', + 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.tar.gz', (_, res, ctx) => res( ctx.status(200), @@ -375,7 +422,7 @@ describe('GitlabUrlReader', () => { 'https://gitlab.com/backstage/mock/tree/main/docs', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'index.md'), 'utf8'), @@ -454,7 +501,7 @@ describe('GitlabUrlReader', () => { describe('search', () => { const archiveBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.tar.gz'), ); const projectGitlabApiResponse = { @@ -471,7 +518,7 @@ describe('GitlabUrlReader', () => { beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 13764a5312..6c272471e2 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -34,6 +34,8 @@ import { SearchOptions, SearchResponse, UrlReader, + ReadUrlResponse, + ReadUrlOptions, } from './types'; export class GitlabUrlReader implements UrlReader { @@ -54,20 +56,37 @@ export class GitlabUrlReader implements UrlReader { ) {} async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; try { - response = await fetch( - builtUrl, - getGitLabRequestOptions(this.integration.config), - ); + response = await fetch(builtUrl, { + headers: { + ...getGitLabRequestOptions(this.integration.config).headers, + ...(options?.etag && { 'If-None-Match': options.etag }), + }, + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } + if (response.status === 304) { + throw new NotModifiedError(); + } + if (response.ok) { - return Buffer.from(await response.text()); + return { + buffer: async () => Buffer.from(await response.text()), + etag: response.headers.get('ETag') ?? undefined, + }; } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -139,7 +158,7 @@ export class GitlabUrlReader implements UrlReader { const archiveGitLabResponse = await fetch( `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, - )}/repository/archive.zip?sha=${branch}`, + )}/repository/archive?sha=${branch}`, getGitLabRequestOptions(this.integration.config), ); if (!archiveGitLabResponse.ok) { @@ -150,7 +169,7 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - return await this.deps.treeResponseFactory.fromZipArchive({ + return await this.deps.treeResponseFactory.fromTarArchive({ stream: (archiveGitLabResponse.body as unknown) as Readable, subpath: filepath, etag: commitSha, diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts index e06612cd15..9f3fbad302 100644 --- a/packages/backend-common/src/reading/GoogleGcsUrlReader.ts +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts @@ -18,6 +18,8 @@ import { Storage } from '@google-cloud/storage'; import { ReaderFactory, ReadTreeResponse, + ReadUrlOptions, + ReadUrlResponse, SearchResponse, UrlReader, } from './types'; @@ -90,6 +92,15 @@ export class GoogleGcsUrlReader implements UrlReader { } } + async readUrl( + url: string, + _options?: ReadUrlOptions, + ): Promise { + // TODO etag is not implemented yet. + const buffer = await this.read(url); + return { buffer: async () => buffer }; + } + async readTree(): Promise { throw new Error('GcsUrlReader does not implement readTree'); } diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts index c9afecf907..4a166cb517 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -15,27 +15,35 @@ */ import { NotAllowedError } from '@backstage/errors'; +import { Logger } from 'winston'; import { ReadTreeOptions, ReadTreeResponse, + ReadUrlOptions, + ReadUrlResponse, SearchOptions, SearchResponse, UrlReader, UrlReaderPredicateTuple, } from './types'; +const MIN_WARNING_INTERVAL_MS = 1000 * 60 * 15; + /** * A UrlReader implementation that selects from a set of UrlReaders * based on a predicate tied to each reader. */ export class UrlReaderPredicateMux implements UrlReader { private readonly readers: UrlReaderPredicateTuple[] = []; + private readonly readerWarnings: Map = new Map(); + + constructor(private readonly logger: Logger) {} register(tuple: UrlReaderPredicateTuple): void { this.readers.push(tuple); } - read(url: string): Promise { + async read(url: string): Promise { const parsed = new URL(url); for (const { predicate, reader } of this.readers) { @@ -47,6 +55,37 @@ export class UrlReaderPredicateMux implements UrlReader { throw new NotAllowedError(`Reading from '${url}' is not allowed`); } + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + const parsed = new URL(url); + + for (const { predicate, reader } of this.readers) { + if (predicate(parsed)) { + if (reader.readUrl) { + return reader.readUrl(url, options); + } + const now = Date.now(); + const lastWarned = this.readerWarnings.get(reader) ?? 0; + if (now > lastWarned + MIN_WARNING_INTERVAL_MS) { + this.readerWarnings.set(reader, now); + this.logger.warn( + `No implementation of readUrl found for ${reader}, this method will be required in the ` + + `future and will replace the 'read' method. See the changelog for more details here: ` + + 'https://github.com/backstage/backstage/blob/master/packages/backend-common/CHANGELOG.md#085', + ); + } + const buffer = await reader.read(url); + return { + buffer: async () => buffer, + }; + } + } + + throw new NotAllowedError(`Reading from '${url}' is not allowed`); + } + async readTree( url: string, options?: ReadTreeOptions, diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index bc77877384..2b3a2f166c 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -43,7 +43,7 @@ export class UrlReaders { * Creates a UrlReader without any known types. */ static create({ logger, config, factories }: CreateOptions): UrlReader { - const mux = new UrlReaderPredicateMux(); + const mux = new UrlReaderPredicateMux(logger); const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config, }); diff --git a/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz new file mode 100644 index 0000000000..c078e07e7e Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.tar.gz differ diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index f7fad4dd7a..8efc833ead 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -22,8 +22,20 @@ import { Config } from '@backstage/config'; * A generic interface for fetching plain data from URLs. */ export type UrlReader = { + /* Used to read a single file and return its content. */ read(url: string): Promise; + + /** + * A replacement for the read method that supports options and complex responses. + * + * Use this whenever it is available, as the read method will be deprecated and + * eventually removed in the future. + */ + readUrl?(url: string, options?: ReadUrlOptions): Promise; + + /* Used to read a file tree and download as a directory. */ readTree(url: string, options?: ReadTreeOptions): Promise; + /* Used to search a file in a tree using a glob pattern. */ search(url: string, options?: SearchOptions): Promise; }; @@ -42,6 +54,40 @@ export type ReaderFactory = (options: { treeResponseFactory: ReadTreeResponseFactory; }) => UrlReaderPredicateTuple[]; +/** + * An options object for readUrl operations. + */ +export type ReadUrlOptions = { + /** + * An etag can be provided to check whether readUrl's response has changed from a previous execution. + * + * In the readUrl() response, an etag is returned along with the data. The etag is a unique identifer + * of the data, usually the commit SHA or etag from the target. + * + * When an etag is given in ReadUrlOptions, readUrl will first compare the etag against the etag + * on the target. If they match, readUrl will throw a NotModifiedError indicating that the readUrl + * response will not differ from the previous response which included this particular etag. If they + * do not match, readUrl will return the rest of ReadUrlResponse along with a new etag. + */ + etag?: string; +}; + +/** + * A response object for readUrl operations. + */ +export type ReadUrlResponse = { + /** + * Returns the data that was read from the remote URL. + */ + buffer(): Promise; + + /** + * Etag returned by content provider. + * Can be used to compare and cache responses when doing subsequent calls. + */ + etag?: string; +}; + /** * An options object for readTree operations. */ @@ -66,14 +112,17 @@ export type ReadTreeOptions = { * In the readTree() response, an etag is returned along with the tree blob. The etag is a unique identifer * of the tree blob, usually the commit SHA or etag from the target. * - * When a etag is given in ReadTreeOptions, readTree will first compare the etag against the etag + * When an etag is given in ReadTreeOptions, readTree will first compare the etag against the etag * on the target branch. If they match, readTree will throw a NotModifiedError indicating that the readTree - * response will not differ from the previous response which included this particular etag. If they mismatch, - * readTree will return the rest of ReadTreeResponse along with a new etag. + * response will not differ from the previous response which included this particular etag. If they + * do not match, readTree will return the rest of ReadTreeResponse along with a new etag. */ etag?: string; }; +/** + * A response object for readTree operations. + */ export type ReadTreeResponse = { /** * files() returns an array of all the files inside the tree and corresponding functions to read their content. @@ -87,7 +136,8 @@ export type ReadTreeResponse = { dir(options?: ReadTreeResponseDirOptions): Promise; /** - * A unique identifer of the tree blob, usually the commit SHA or etag from the target. + * Etag returned by content provider. + * Can be used to compare and cache responses when doing subsequent calls. */ etag: string; }; diff --git a/packages/backend-common/src/scm/git.test.ts b/packages/backend-common/src/scm/git.test.ts index af9dfeef1b..0b70edc45d 100644 --- a/packages/backend-common/src/scm/git.test.ts +++ b/packages/backend-common/src/scm/git.test.ts @@ -201,14 +201,16 @@ describe('Git', () => { describe('init', () => { it('should call isomorphic-git with the correct arguments', async () => { const dir = '/some/mock/dir'; + const defaultBranch = 'master'; const git = Git.fromAuth({}); - await git.init({ dir }); + await git.init({ dir, defaultBranch }); expect(isomorphic.init).toHaveBeenCalledWith({ fs, dir, + defaultBranch, }); }); }); diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index 3afa551d5f..e786c279a1 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -150,12 +150,19 @@ export class Git { }); } - async init({ dir }: { dir: string }): Promise { + async init({ + dir, + defaultBranch = 'master', + }: { + dir: string; + defaultBranch?: string; + }): Promise { this.config.logger?.info(`Init git repository {dir=${dir}}`); return git.init({ fs, dir, + defaultBranch, }); } diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 3079041c7f..65852c3a4f 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -36,7 +36,7 @@ "knex": "^0.95.1", "mysql2": "^2.2.5", "pg": "^8.3.0", - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.1", "testcontainers": "^7.10.0", "uuid": "^8.0.0" }, diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 65d28457d0..9659932635 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,18 @@ # example-backend +## 0.2.35 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-scaffolder-backend@0.12.4 + - @backstage/backend-common@0.8.4 + - @backstage/plugin-auth-backend@0.3.15 + - @backstage/plugin-catalog-backend@0.11.0 + - @backstage/plugin-techdocs-backend@0.8.5 + - @backstage/catalog-client@0.3.15 + - @backstage/plugin-kafka-backend@0.2.7 + ## 0.2.32 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index f4f304d714..c3e693865f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.32", + "version": "0.2.35", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,28 +27,30 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.8.2", - "@backstage/catalog-client": "^0.3.13", + "@backstage/backend-common": "^0.8.4", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.2", "@backstage/config": "^0.1.5", + "@backstage/integration": "^0.5.6", "@backstage/plugin-app-backend": "^0.3.13", - "@backstage/plugin-auth-backend": "^0.3.12", + "@backstage/plugin-auth-backend": "^0.3.15", "@backstage/plugin-badges-backend": "^0.1.6", - "@backstage/plugin-catalog-backend": "^0.10.2", + "@backstage/plugin-catalog-backend": "^0.11.0", "@backstage/plugin-code-coverage-backend": "^0.1.6", "@backstage/plugin-graphql-backend": "^0.1.8", "@backstage/plugin-kubernetes-backend": "^0.3.8", - "@backstage/plugin-kafka-backend": "^0.2.6", + "@backstage/plugin-kafka-backend": "^0.2.7", "@backstage/plugin-proxy-backend": "^0.2.9", "@backstage/plugin-rollbar-backend": "^0.1.11", - "@backstage/plugin-scaffolder-backend": "^0.12.0", + "@backstage/plugin-scaffolder-backend": "^0.12.4", + "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.1", "@backstage/plugin-search-backend": "^0.2.0", "@backstage/plugin-search-backend-node": "^0.2.0", - "@backstage/plugin-techdocs-backend": "^0.8.2", + "@backstage/plugin-techdocs-backend": "^0.8.5", "@backstage/plugin-todo-backend": "^0.1.6", "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", - "azure-devops-node-api": "^10.1.1", + "azure-devops-node-api": "^10.2.2", "dockerode": "^3.2.1", "example-app": "^0.2.32", "express": "^4.17.1", @@ -56,11 +58,11 @@ "knex": "^0.95.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.1", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.0", + "@backstage/cli": "^0.7.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/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 09b3732301..619134a990 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -14,19 +14,9 @@ * limitations under the License. */ -import { - DockerContainerRunner, - SingleHostDiscovery, -} from '@backstage/backend-common'; +import { DockerContainerRunner } from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; -import { - CookieCutter, - CreateReactAppTemplater, - createRouter, - Preparers, - Publishers, - Templaters, -} from '@backstage/plugin-scaffolder-backend'; +import { createRouter } from '@backstage/plugin-scaffolder-backend'; import Docker from 'dockerode'; import { Router } from 'express'; import type { PluginEnvironment } from '../types'; @@ -36,27 +26,15 @@ export default async function createPlugin({ config, database, reader, + discovery, }: PluginEnvironment): Promise { const dockerClient = new Docker(); const containerRunner = new DockerContainerRunner({ dockerClient }); - const cookiecutterTemplater = new CookieCutter({ containerRunner }); - const craTemplater = new CreateReactAppTemplater({ containerRunner }); - const templaters = new Templaters(); - - templaters.register('cookiecutter', cookiecutterTemplater); - templaters.register('cra', craTemplater); - - const preparers = await Preparers.fromConfig(config, { logger }); - const publishers = await Publishers.fromConfig(config, { logger }); - - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ - preparers, - templaters, - publishers, + containerRunner, logger, config, database, diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 830edbb600..2bae1fc11e 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/catalog-client +## 0.3.15 + +### Patch Changes + +- ca080cab8: Don't crash if the entities response doesn't include the entities name and kind + ## 0.3.14 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 24ac72bb67..425e70c20d 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.14", + "version": "0.3.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -35,7 +35,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@types/jest": "^26.0.7", "msw": "^0.29.0" }, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 6d43af85ed..4e3d95f99f 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -151,6 +151,26 @@ describe('CatalogClient', () => { expect(response.items).toEqual([]); }); + + it('handles field filtered entities', async () => { + server.use( + rest.get(`${mockBaseUrl}/entities`, (_req, res, ctx) => { + return res(ctx.json([{ apiVersion: '1' }, { apiVersion: '2' }])); + }), + ); + + const response = await client.getEntities( + { + fields: ['apiVersion'], + }, + { token }, + ); + + expect(response.items).toEqual([ + { apiVersion: '1' }, + { apiVersion: '2' }, + ]); + }); }); describe('getLocationById', () => { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 10c282f786..5dfcddde0c 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -92,6 +92,16 @@ export class CatalogClient implements CatalogApi { ); const refCompare = (a: Entity, b: Entity) => { + // in case field filtering is used, these fields might not be part of the response + if ( + a.metadata?.name === undefined || + a.kind === undefined || + b.metadata?.name === undefined || + b.kind === undefined + ) { + return 0; + } + const aRef = stringifyEntityRef(a); const bRef = stringifyEntityRef(b); if (aRef < bRef) { diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 68644bef87..5d055566be 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -492,29 +492,6 @@ export { SystemEntityV1alpha1 } // @public (undocumented) export const systemEntityV1alpha1Validator: KindValidator; -// @public (undocumented) -interface TemplateEntityV1alpha1 extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; - // (undocumented) - kind: 'Template'; - // (undocumented) - spec: { - type: string; - templater: string; - path?: string; - schema: JSONSchema; - owner?: string; - }; -} - -export { TemplateEntityV1alpha1 as TemplateEntity } - -export { TemplateEntityV1alpha1 } - -// @public (undocumented) -export const templateEntityV1alpha1Validator: KindValidator; - // @public (undocumented) export interface TemplateEntityV1beta2 extends Entity { // (undocumented) diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts deleted file mode 100644 index f03519a3c6..0000000000 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - TemplateEntityV1alpha1, - templateEntityV1alpha1Validator as validator, -} from './TemplateEntityV1alpha1'; - -describe('templateEntityV1alpha1Validator', () => { - let entity: TemplateEntityV1alpha1; - - beforeEach(() => { - entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - name: 'test', - }, - spec: { - type: 'website', - templater: 'cookiecutter', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-a@example.com', - }, - }; - }); - - it('happy path: accepts valid data', async () => { - await expect(validator.check(entity)).resolves.toBe(true); - }); - - it('silently accepts v1beta1 as well', async () => { - (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(validator.check(entity)).resolves.toBe(true); - }); - - it('ignores unknown apiVersion', async () => { - (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(validator.check(entity)).resolves.toBe(false); - }); - - it('ignores unknown kind', async () => { - (entity as any).kind = 'Wizard'; - await expect(validator.check(entity)).resolves.toBe(false); - }); - - it('rejects missing type', async () => { - delete (entity as any).spec.type; - await expect(validator.check(entity)).rejects.toThrow(/type/); - }); - - it('accepts any other type', async () => { - (entity as any).spec.type = 'hallo'; - await expect(validator.check(entity)).resolves.toBe(true); - }); - - it('rejects empty type', async () => { - (entity as any).spec.type = ''; - await expect(validator.check(entity)).rejects.toThrow(/type/); - }); - - it('rejects missing templater', async () => { - (entity as any).spec.templater = ''; - await expect(validator.check(entity)).rejects.toThrow(/templater/); - }); - it('accepts missing owner', async () => { - delete (entity as any).spec.owner; - await expect(validator.check(entity)).resolves.toBe(true); - }); - it('rejects empty owner', async () => { - (entity as any).spec.owner = ''; - await expect(validator.check(entity)).rejects.toThrow(/owner/); - }); - it('rejects wrong type owner', async () => { - (entity as any).spec.owner = 5; - await expect(validator.check(entity)).rejects.toThrow(/owner/); - }); -}); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts deleted file mode 100644 index bdc6f35df2..0000000000 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Entity } from '../entity/Entity'; -import schema from '../schema/kinds/Template.v1alpha1.schema.json'; -import type { JSONSchema } from '../types'; -import { ajvCompiledJsonSchemaValidator } from './util'; - -export interface TemplateEntityV1alpha1 extends Entity { - apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; - kind: 'Template'; - spec: { - type: string; - templater: string; - path?: string; - schema: JSONSchema; - owner?: string; - }; -} - -export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - schema, -); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index ccdf0051db..be9f7a0d6a 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -50,11 +50,6 @@ export type { SystemEntityV1alpha1 as SystemEntity, SystemEntityV1alpha1, } from './SystemEntityV1alpha1'; -export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1'; -export type { - TemplateEntityV1alpha1 as TemplateEntity, - TemplateEntityV1alpha1, -} from './TemplateEntityV1alpha1'; export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2'; export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2'; export type { KindValidator } from './types'; diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json deleted file mode 100644 index 5a0c0efa73..0000000000 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "TemplateV1alpha1", - "description": "A Template describes a skeleton for use with the Scaffolder. It is used for describing what templating library is supported, and also for documenting the variables that the template requires using JSON Forms Schema.", - "examples": [ - { - "apiVersion": "backstage.io/v1alpha1", - "kind": "Template", - "metadata": { - "name": "react-ssr-template", - "title": "React SSR Template", - "description": "Next.js application skeleton for creating isomorphic web applications.", - "tags": ["recommended", "react"] - }, - "spec": { - "owner": "artist-relations-team", - "templater": "cookiecutter", - "type": "website", - "path": ".", - "schema": { - "required": ["component-id", "description"], - "properties": { - "component_id": { - "title": "Name", - "type": "string", - "description": "Unique name of the component" - }, - "description": { - "title": "Description", - "type": "string", - "description": "Description of the component" - } - } - } - } - } - ], - "allOf": [ - { - "$ref": "Entity" - }, - { - "type": "object", - "required": ["spec"], - "properties": { - "apiVersion": { - "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] - }, - "kind": { - "enum": ["Template"] - }, - "metadata": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.", - "examples": ["React SSR Template"], - "minLength": 1 - } - } - }, - "spec": { - "type": "object", - "required": ["type", "templater", "schema"], - "properties": { - "type": { - "type": "string", - "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", - "examples": ["service", "website", "library"], - "minLength": 1 - }, - "templater": { - "type": "string", - "description": "The templating library that is supported by the template skeleton.", - "examples": ["cookiecutter"], - "minLength": 1 - }, - "path": { - "type": "string", - "description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.", - "examples": ["./cookiecutter/skeleton"], - "minLength": 1 - }, - "schema": { - "type": "object", - "description": "The JSONSchema describing the inputs for the template." - }, - "owner": { - "type": "string", - "description": "The user (or group) owner of the template", - "minLength": 1 - } - } - } - } - } - ] -} diff --git a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts index f9b2b6b957..c194774906 100644 --- a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.test.ts @@ -76,9 +76,10 @@ describe('KubernetesValidatorFunctions', () => { ['a-b', true], ['-a-b', false], ['a-b-', false], - ['a--b', false], + ['a--b', true], ['a_b', true], ['a.b', true], + ['a..b', true], ])(`isValidObjectName %p ? %p`, (value, matches) => { expect(KubernetesValidatorFunctions.isValidObjectName(value)).toBe(matches); }); @@ -114,9 +115,10 @@ describe('KubernetesValidatorFunctions', () => { ['a-b', true], ['-a-b', false], ['a-b-', false], - ['a--b', false], + ['a--b', true], ['a_b', true], ['a.b', true], + ['a..b', true], ['a/a', true], ['a-b.c/a', true], ['a--b.c/a', false], @@ -150,9 +152,10 @@ describe('KubernetesValidatorFunctions', () => { ['a-b', true], ['-a-b', false], ['a-b-', false], - ['a--b', false], + ['a--b', true], ['a_b', true], ['a.b', true], + ['a..b', true], ])(`isValidLabelValue %p ? %p`, (value, matches) => { expect(KubernetesValidatorFunctions.isValidLabelValue(value)).toBe(matches); }); @@ -169,9 +172,10 @@ describe('KubernetesValidatorFunctions', () => { ['a-b', true], ['-a-b', false], ['a-b-', false], - ['a--b', false], + ['a--b', true], ['a_b', true], ['a.b', true], + ['a..b', true], ['a/a', true], ['a-b.c/a', true], ['a--b.c/a', false], diff --git a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts index 049678e3a9..86d27e7b13 100644 --- a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts @@ -48,7 +48,7 @@ export class KubernetesValidatorFunctions { typeof value === 'string' && value.length >= 1 && value.length <= 63 && - /^[a-z0-9A-Z]+([-_.][a-z0-9A-Z]+)*$/.test(value) + /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/.test(value) ); } diff --git a/packages/cli-common/CHANGELOG.md b/packages/cli-common/CHANGELOG.md new file mode 100644 index 0000000000..52a247f57a --- /dev/null +++ b/packages/cli-common/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/cli-common + +## 0.1.2 + +### Patch Changes + +- ab5cc376f: Add new `isChildPath` export diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index bcdc023c22..7141d27f3a 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -7,6 +7,9 @@ // @public export function findPaths(searchDir: string): Paths; +// @public +export function isChildPath(base: string, path: string): boolean; + // @public (undocumented) export type Paths = { ownDir: string; diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index a9fd066ef4..16f10717f9 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.1", + "version": "0.1.2", "private": false, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index d36314ba35..1f4f17fe9f 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -15,4 +15,5 @@ */ export { findPaths } from './paths'; +export { isChildPath } from './isChildPath'; export type { Paths } from './paths'; diff --git a/packages/cli-common/src/isChildPath.test.ts b/packages/cli-common/src/isChildPath.test.ts new file mode 100644 index 0000000000..4c0d94f07c --- /dev/null +++ b/packages/cli-common/src/isChildPath.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { posix, win32 } from 'path'; + +describe('isChildPath', () => { + it('should check child posix paths', () => { + jest.isolateModules(() => { + jest.setMock('path', posix); + const { isChildPath } = require('./isChildPath'); + + expect(isChildPath('/', '/')).toBe(true); + expect(isChildPath('/x', '/x')).toBe(true); + expect(isChildPath('/x', '/x/y')).toBe(true); + expect(isChildPath('/x', '/x/x')).toBe(true); + expect(isChildPath('/x', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y/z', '/x/y/z')).toBe(true); + expect(isChildPath('/x/a b c/z', '/x/a b c/z')).toBe(true); + expect(isChildPath('/', '/ yz')).toBe(true); + + expect(isChildPath('/x', '/y')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + expect(isChildPath('/x', '/x y')).toBe(false); + expect(isChildPath('/x y', '/x yz')).toBe(false); + expect(isChildPath('/ yz', '/')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + + jest.dontMock('path'); + }); + }); + + it('should check child win32 paths', () => { + jest.isolateModules(() => { + jest.setMock('path', win32); + const { isChildPath } = require('./isChildPath'); + + expect(isChildPath('/x', '/x')).toBe(true); + expect(isChildPath('/x', '/x/y')).toBe(true); + expect(isChildPath('/x', '/x/x')).toBe(true); + expect(isChildPath('/x', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y', '/x/y/z')).toBe(true); + expect(isChildPath('/x/y/z', '/x/y/z')).toBe(true); + expect(isChildPath('Z:', 'Z:')).toBe(true); + expect(isChildPath('C:/', 'c:/')).toBe(true); + expect(isChildPath('C:/x', 'C:/x')).toBe(true); + expect(isChildPath('C:/x', 'c:/x')).toBe(true); + expect(isChildPath('C:/x', 'C:/x/y')).toBe(true); + expect(isChildPath('d:/x', 'D:/x/y')).toBe(true); + + expect(isChildPath('/x', '/y')).toBe(false); + expect(isChildPath('/x', '/')).toBe(false); + expect(isChildPath('C:/', 'D:/')).toBe(false); + expect(isChildPath('C:/x', 'D:/x')).toBe(false); + expect(isChildPath('D:/x', 'CD:/x')).toBe(false); + expect(isChildPath('D:/x', 'D:/y')).toBe(false); + + jest.dontMock('path'); + }); + }); +}); diff --git a/packages/cli-common/src/isChildPath.ts b/packages/cli-common/src/isChildPath.ts new file mode 100644 index 0000000000..f48c92db32 --- /dev/null +++ b/packages/cli-common/src/isChildPath.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { relative, isAbsolute } from 'path'; + +/** + * Checks if path is the same as or a child path of base. + */ +export function isChildPath(base: string, path: string): boolean { + const relativePath = relative(base, path); + if (relativePath === '') { + // The same directory + return true; + } + + const outsideBase = relativePath.startsWith('..'); // not outside base + const differentDrive = isAbsolute(relativePath); // on Windows, this means dir is on a different drive from base. + + return !outsideBase && !differentDrive; +} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 87b726600b..a8cacedf22 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/cli +## 0.7.3 + +### Patch Changes + +- a93e60fdc: Updated dependencies +- 55f49fcc7: Update dependencies +- ab5cc376f: Use new `isChildPath` export from `@backstage/cli-common` +- Updated dependencies + - @backstage/cli-common@0.1.2 + ## 0.7.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index b0e6b021cc..c9bde05bd7 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.7.2", + "version": "0.7.3", "private": false, "publishConfig": { "access": "public" @@ -30,7 +30,7 @@ "dependencies": { "@babel/core": "^7.4.4", "@babel/plugin-transform-modules-commonjs": "^7.4.4", - "@backstage/cli-common": "^0.1.1", + "@backstage/cli-common": "^0.1.2", "@backstage/config": "^0.1.5", "@backstage/config-loader": "^0.6.4", "@hot-loader/react-dom": "^16.13.0", @@ -40,7 +40,7 @@ "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^13.0.0", - "@rollup/plugin-yaml": "^2.1.1", + "@rollup/plugin-yaml": "^3.0.0", "@spotify/eslint-config-base": "^9.0.0", "@spotify/eslint-config-react": "^10.0.0", "@spotify/eslint-config-typescript": "^10.0.0", @@ -61,7 +61,7 @@ "chalk": "^4.0.0", "chokidar": "^3.3.1", "commander": "^6.1.0", - "css-loader": "^3.5.3", + "css-loader": "^5.2.6", "dashify": "^2.0.0", "diff": "^5.0.0", "esbuild": "^0.8.56", @@ -77,7 +77,7 @@ "express": "^4.17.1", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^6.2.9", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.3", "html-webpack-plugin": "^4.3.0", "inquirer": "^7.0.4", @@ -118,11 +118,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.8.3", + "@backstage/backend-common": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", - "@backstage/core-app-api": "^0.1.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@backstage/theme": "^0.2.8", @@ -146,7 +146,7 @@ "del": "^6.0.0", "mock-fs": "^4.13.0", "nodemon": "^2.0.2", - "ts-node": "^9.1.1" + "ts-node": "^10.0.0" }, "files": [ "asset-types", diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 2360078a18..aa6a6b4e2e 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { ResolvePlugin } from 'webpack'; -import { isChildPath } from './paths'; +import { isChildPath } from '@backstage/cli-common'; import { LernaPackage } from './types'; // Enables proper resolution of packages when linking in external packages. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 89f677109d..75e94af716 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -22,9 +22,10 @@ import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; import StartServerPlugin from 'start-server-webpack-plugin'; import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; +import { isChildPath } from '@backstage/cli-common'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths, isChildPath } from './paths'; +import { BundlingPaths } from './paths'; import { transforms } from './transforms'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 01fdcdefe5..d465b3010f 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -15,25 +15,8 @@ */ 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/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index 3b617f4623..d5291222d8 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/codemods +## 0.1.4 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.1.4 + - @backstage/core-components@0.1.4 + - @backstage/cli-common@0.1.2 + ## 0.1.3 ### Patch Changes diff --git a/packages/codemods/package.json b/packages/codemods/package.json index d9ea7fb1b7..2b557685bd 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/codemods", "description": "A collection of codemods for Backstage projects", - "version": "0.1.3", + "version": "0.1.4", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "backstage-codemods": "bin/backstage-codemods" }, "dependencies": { - "@backstage/cli-common": "0.1.1", + "@backstage/cli-common": "0.1.2", "@backstage/core-app-api": "*", "@backstage/core-components": "*", "@backstage/core-plugin-api": "*", @@ -43,7 +43,7 @@ "@types/jscodeshift": "^0.11.0", "@types/node": "^14.14.32", "commander": "^6.1.0", - "ts-node": "^9.1.1" + "ts-node": "^10.0.0" }, "nodemonConfig": { "watch": "./src", diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 4a0ede9873..d5a4f600fd 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -34,7 +34,7 @@ "@backstage/config": "^0.1.5", "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "json-schema": "^0.3.0", "json-schema-merge-allof": "^0.8.1", "typescript-json-schema": "^0.50.1", diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index c96fbb5aba..4f09f231c3 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/core-app-api +## 0.1.4 + +### Patch Changes + +- 62abffee4: Reintroduce export of `defaultConfigLoader`. +- Updated dependencies + - @backstage/core-components@0.1.4 + ## 0.1.3 ### Patch Changes diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 42db08a136..0f6773497b 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-app-api", "description": "Core app API used by Backstage apps", - "version": "0.1.3", + "version": "0.1.4", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.2", + "@backstage/core-components": "^0.1.4", "@backstage/config": "^0.1.3", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", @@ -44,7 +44,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.14", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index db49ad5adb..7f3bc09225 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/core-components +## 0.1.4 + +### Patch Changes + +- f423891ee: Fixed sizing of the System diagram when the rendered graph was wider than the container. +- 3db266fe4: Make `ErrorBoundary` display more helpful information about the error that + occurred. + + The `slackChannel` (optional) prop can now be passed as an object on the form + `{ name: string; href?: string; }` in addition to the old string form. If you + are using the error boundary like + + ```tsx + + + + ``` + + you may like to migrate it to + + ```tsx + const support = { + name: '#support', + href: 'https://slack.com/channels/your-channel', + }; + + + + + ``` + + Also deprecated the prop `slackChannel` on `TabbedCard` and `InfoCard`, while + adding the prop `errorBoundaryProps` to replace it. + +- e8c65b068: Clear the previously selected sign-in provider on failure + ## 0.1.3 ### Patch Changes diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 428c0e9134..1c2e160bd5 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-components", "description": "Core components used by Backstage plugins and apps", - "version": "0.1.3", + "version": "0.1.4", "private": false, "publishConfig": { "access": "public", @@ -70,8 +70,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/core-app-api": "^0.1.2", - "@backstage/cli": "^0.7.1", + "@backstage/core-app-api": "^0.1.4", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 5c0442f906..89f7421d2a 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -262,7 +262,7 @@ export function DependencyGraph({ diff --git a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 39cce709c0..d477494964 100644 --- a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { Button, makeStyles, Typography } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; +import { Link } from '../Link'; import { EmptyState } from './EmptyState'; import { CodeSnippet } from '../CodeSnippet'; @@ -73,9 +74,9 @@ export const MissingAnnotationEmptyState = ({ annotation }: Props) => { /> diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index dd169390a5..7853c4f52f 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -37,9 +37,16 @@ export type LinkProps = MaterialLinkProps & */ export const Link = React.forwardRef((props, ref) => { const to = String(props.to); - return isExternalUri(to) ? ( + const external = isExternalUri(to); + const newWindow = external && !!/^https?:/.exec(to); + return external ? ( // External links - + ) : ( // Interact with React Router for internal links diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx index 2167da6fd2..296df22ecd 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx @@ -65,8 +65,8 @@ type Props = { }; const renderers = { - code: ({ language, value }: { language: string; value: string }) => { - return ; + code: ({ language, value }: { language: string; value?: string }) => { + return ; }, }; diff --git a/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx b/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx index 231cd179c7..ccfb706bf9 100644 --- a/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx +++ b/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx @@ -76,7 +76,7 @@ export const ResponseErrorPanel = ({ @@ -87,10 +87,9 @@ export const ResponseErrorPanel = ({ } /> - + ); diff --git a/packages/core-components/src/layout/InfoCard/InfoCard.tsx b/packages/core-components/src/layout/InfoCard/InfoCard.tsx index 763038388b..cc15bbd9f4 100644 --- a/packages/core-components/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core-components/src/layout/InfoCard/InfoCard.tsx @@ -37,7 +37,6 @@ const useStyles = makeStyles(theme => ({ }, }, header: { - display: 'inline-block', padding: theme.spacing(2, 2, 2, 2.5), }, headerTitle: { @@ -121,6 +120,7 @@ type Props = { children?: ReactNode; headerStyle?: object; headerProps?: CardHeaderProps; + action?: ReactNode; actionsClassName?: string; actions?: ReactNode; cardClassName?: string; @@ -141,6 +141,7 @@ export const InfoCard = ({ children, headerStyle, headerProps, + action, actionsClassName, actions, cardClassName, @@ -190,6 +191,7 @@ export const InfoCard = ({ }} title={title} subheader={subheader} + action={action} style={{ ...headerStyle }} titleTypographyProps={titleTypographyProps} {...headerProps} diff --git a/packages/core-components/src/layout/Page/Page.tsx b/packages/core-components/src/layout/Page/Page.tsx index 8acb54c268..8d1dfe2943 100644 --- a/packages/core-components/src/layout/Page/Page.tsx +++ b/packages/core-components/src/layout/Page/Page.tsx @@ -25,7 +25,8 @@ const useStyles = makeStyles(() => ({ "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", gridTemplateRows: 'auto auto 1fr', gridTemplateColumns: 'auto 1fr auto', - minHeight: '100vh', + height: '100vh', + overflowY: 'auto', }, })); diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index f6edac81b7..65da4d2d75 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { makeStyles } from '@material-ui/core'; +import { makeStyles, useMediaQuery } from '@material-ui/core'; import clsx from 'clsx'; import React, { useRef, useState, useContext, PropsWithChildren } from 'react'; import { sidebarConfig, SidebarContext } from './config'; @@ -82,6 +82,9 @@ export const Sidebar = ({ children, }: PropsWithChildren) => { const classes = useStyles(); + const isSmallScreen = useMediaQuery(theme => + theme.breakpoints.down('md'), + ); const [state, setState] = useState(State.Closed); const hoverTimerRef = useRef(); const { isPinned } = useContext(SidebarPinStateContext); @@ -94,7 +97,7 @@ export const Sidebar = ({ clearTimeout(hoverTimerRef.current); hoverTimerRef.current = undefined; } - if (state !== State.Open) { + if (state !== State.Open && !isSmallScreen) { hoverTimerRef.current = window.setTimeout(() => { hoverTimerRef.current = undefined; setState(State.Open); @@ -122,6 +125,8 @@ export const Sidebar = ({ } }; + const isOpen = (state === State.Open && !isSmallScreen) || isPinned; + return (
{children} diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 9bccffc6c4..28abbce53c 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -21,8 +21,10 @@ import { makeStyles, styled, TextField, + Theme, Typography, } from '@material-ui/core'; +import { CreateCSSProperties } from '@material-ui/core/styles/withStyles'; import SearchIcon from '@material-ui/icons/Search'; import clsx from 'clsx'; import React, { @@ -290,3 +292,32 @@ export const SidebarDivider = styled('hr')({ border: 'none', margin: '12px 0px', }); + +const styledScrollbar = (theme: Theme): CreateCSSProperties => ({ + overflowY: 'auto', + '&::-webkit-scrollbar': { + backgroundColor: theme.palette.background.default, + width: '5px', + borderRadius: '5px', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: theme.palette.text.hint, + borderRadius: '5px', + }, +}); + +export const SidebarScrollWrapper = styled('div')(({ theme }) => { + const scrollbarStyles = styledScrollbar(theme); + return { + flex: '0 1 auto', + overflowX: 'hidden', + // 5px space to the right of the scrollbar + width: 'calc(100% - 5px)', + // Display at least one item in the container + // Question: Can this be a config/theme variable - if so, which? :/ + minHeight: '48px', + overflowY: 'hidden', + '@media (hover: none)': scrollbarStyles, + '&:hover': scrollbarStyles, + }; +}); diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index fc0e29f9a6..58e193dbaf 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -23,6 +23,7 @@ export { SidebarSearchField, SidebarSpace, SidebarSpacer, + SidebarScrollWrapper, } from './Items'; export { IntroCard, SidebarIntro } from './Intro'; export { diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 0f5d3daf2e..19915e6aa9 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -143,6 +143,7 @@ export const useSignInProviders = ( if (didCancel) { return; } + localStorage.removeItem(PROVIDER_STORAGE_KEY); errorApi.post(error); setLoading(false); }); diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index b68ed6f85b..5a5949b53a 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/create-app +## 0.3.29 + +### Patch Changes + +- Updated dependencies + - @backstage/cli-common@0.1.2 + ## 0.3.28 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index d21563dad5..73c41a79f0 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.28", + "version": "0.3.29", "private": false, "publishConfig": { "access": "public" @@ -27,10 +27,10 @@ "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.1", + "@backstage/cli-common": "^0.1.2", "chalk": "^4.0.0", "commander": "^6.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.3", "inquirer": "^7.0.4", "ora": "^5.3.0", @@ -41,7 +41,7 @@ "@types/inquirer": "^7.3.1", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", - "ts-node": "^9.1.1" + "ts-node": "^10.0.0" }, "peerDependencies": { "@backstage/backend-common": "*", diff --git a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx index 5e5a71a635..d8665a2f9b 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx @@ -34,6 +34,7 @@ import { SidebarItem, SidebarDivider, SidebarSpace, + SidebarScrollWrapper, } from '@backstage/core-components'; const useSidebarLogoStyles = makeStyles({ @@ -82,7 +83,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( {/* 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 8f5a116de4..44af516099 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 @@ -40,7 +40,7 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.1", {{/if}} "winston": "^3.2.1" }, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 333ffa11df..a0201cec01 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -4,12 +4,7 @@ import { } from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { - CookieCutter, - CreateReactAppTemplater, createRouter, - Preparers, - Publishers, - Templaters, } from '@backstage/plugin-scaffolder-backend'; import Docker from 'dockerode'; import { Router } from 'express'; @@ -24,23 +19,11 @@ export default async function createPlugin({ const dockerClient = new Docker(); const containerRunner = new DockerContainerRunner({ dockerClient }); - const cookiecutterTemplater = new CookieCutter({ containerRunner }); - const craTemplater = new CreateReactAppTemplater({ containerRunner }); - const templaters = new Templaters(); - - templaters.register('cookiecutter', cookiecutterTemplater); - templaters.register('cra', craTemplater); - - const preparers = await Preparers.fromConfig(config, { logger }); - const publishers = await Publishers.fromConfig(config, { logger }); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); return await createRouter({ - preparers, - templaters, - publishers, + containerRunner, logger, config, database, diff --git a/packages/docgen/package.json b/packages/docgen/package.json index a7d7615e19..d6670bcf83 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -28,9 +28,9 @@ "dependencies": { "chalk": "^4.0.0", "commander": "^6.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "github-slugger": "^1.3.0", - "ts-node": "^9.1.1", + "ts-node": "^10.0.0", "typescript": "^4.0.3" }, "devDependencies": { diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 77a3e3960a..b4e61a7cc1 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -30,11 +30,11 @@ "chalk": "^4.0.0", "commander": "^6.1.0", "cross-fetch": "^3.0.6", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.3", "pgtools": "^0.3.0", "tree-kill": "^1.2.2", - "ts-node": "^9.1.1", + "ts-node": "^10.0.0", "zombie": "^6.1.4" }, "nodemonConfig": { diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 3044922a00..157446ad4c 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/integration +## 0.5.7 + +### Patch Changes + +- 22a60518c: Support ingesting multiple GitHub organizations via a new `GithubMultiOrgReaderProcessor`. + + This new processor handles namespacing created groups according to the org of the associated GitHub team to prevent potential name clashes between organizations. Be aware that this processor is considered alpha and may not be compatible with future org structures in the catalog. + + NOTE: This processor only fully supports auth via GitHub Apps + + To install this processor, import and add it as follows: + + ```typescript + // Typically in packages/backend/src/plugins/catalog.ts + import { GithubMultiOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + // ... + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + builder.addProcessor( + GithubMultiOrgReaderProcessor.fromConfig(env.config, { + logger: env.logger, + }), + ); + // ... + } + ``` + + Configure in your `app-config.yaml` by pointing to your GitHub instance and optionally list which GitHub organizations you wish to import. You can also configure what namespace you want to set for teams from each org. If unspecified, the org name will be used as the namespace. If no organizations are listed, by default this processor will import from all organizations accessible by all configured GitHub Apps: + + ```yaml + catalog: + locations: + - type: github-multi-org + target: https://github.myorg.com + + processors: + githubMultiOrg: + orgs: + - name: fooOrg + groupNamespace: foo + - name: barOrg + groupNamespace: bar + - name: awesomeOrg + - name: anotherOrg + ``` + ## 0.5.6 ### Patch Changes diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 31cbc8b5d8..afd048cb13 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -5,6 +5,7 @@ ```ts import { Config } from '@backstage/config'; +import { RestEndpointMethodTypes } from '@octokit/rest'; // @public (undocumented) export class AzureIntegration implements ScmIntegration { @@ -106,6 +107,15 @@ export function getGitLabFileFetchUrl(url: string, config: GitLabIntegrationConf // @public export function getGitLabRequestOptions(config: GitLabIntegrationConfig): RequestInit; +// @public (undocumented) +export class GithubAppCredentialsMux { + constructor(config: GitHubIntegrationConfig); + // (undocumented) + getAllInstallations(): Promise; + // (undocumented) + getAppToken(owner: string, repo?: string): Promise; +} + // @public (undocumented) export class GithubCredentialsProvider { // (undocumented) diff --git a/packages/integration/package.json b/packages/integration/package.json index 076ba70aba..9313906ce1 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.5.6", + "version": "0.5.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -37,7 +37,7 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.7.0", + "@backstage/cli": "^0.7.3", "@backstage/config-loader": "^0.6.3", "@backstage/test-utils": "^0.1.13", "@types/jest": "^26.0.7", diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 62f81179ee..0de1d5aaab 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -192,7 +192,7 @@ describe('bitbucket core', () => { config, ); expect(result).toEqual( - 'https://bitbucket.org/backstage/mock/get/master.tgz', + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', ); }); }); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index 0a1b247f10..f8f8f56fa8 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -98,7 +98,7 @@ export async function getBitbucketDownloadUrl( // /docs/index.md will download the docs folder and everything below it const path = filepath ? `&path=${encodeURIComponent(filepath)}` : ''; const archiveUrl = isHosted - ? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tgz` + ? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tar.gz` : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=tgz&at=${branch}&prefix=${project}-${repoName}${path}`; return archiveUrl; diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index 18cad6fb78..85df1bb4e1 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -15,6 +15,7 @@ */ const octokit = { + paginate: async (fn: any) => (await fn()).data, apps: { listInstallations: jest.fn(), createInstallationAccessToken: jest.fn(), @@ -53,7 +54,7 @@ describe('GithubCredentialsProvider tests', () => { jest.resetAllMocks(); }); it('create repository specific tokens', async () => { - octokit.apps.listInstallations.mockResolvedValueOnce({ + octokit.apps.listInstallations.mockResolvedValue({ headers: { etag: '123', }, @@ -72,7 +73,6 @@ describe('GithubCredentialsProvider tests', () => { }, ], } as RestEndpointMethodTypes['apps']['listInstallations']['response']); - octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ data: { @@ -84,12 +84,8 @@ describe('GithubCredentialsProvider tests', () => { const { token, headers, type } = await github.getCredentials({ url: 'https://github.com/backstage/foobar', }); - const { token: accessToken2 } = await github.getCredentials({ - url: 'https://github.com/backstage/foobar', - }); expect(type).toEqual('app'); expect(token).toEqual('secret_token'); - expect(token).toEqual(accessToken2); expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); // fallback to the configured token if no application is matching @@ -107,7 +103,7 @@ describe('GithubCredentialsProvider tests', () => { }); it('creates tokens for an organization', async () => { - octokit.apps.listInstallations.mockResolvedValueOnce({ + octokit.apps.listInstallations.mockResolvedValue({ headers: { etag: '123', }, @@ -121,7 +117,6 @@ describe('GithubCredentialsProvider tests', () => { }, ], } as RestEndpointMethodTypes['apps']['listInstallations']['response']); - octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ data: { @@ -133,17 +128,13 @@ describe('GithubCredentialsProvider tests', () => { const { token, headers } = await github.getCredentials({ url: 'https://github.com/backstage', }); - const { token: accessToken2 } = await github.getCredentials({ - url: 'https://github.com/backstage', - }); expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); expect(token).toEqual('secret_token'); - expect(token).toEqual(accessToken2); }); it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => { - octokit.apps.listInstallations.mockResolvedValueOnce({ + octokit.apps.listInstallations.mockResolvedValue({ headers: { etag: '123', }, @@ -157,7 +148,6 @@ describe('GithubCredentialsProvider tests', () => { }, ], } as RestEndpointMethodTypes['apps']['listInstallations']['response']); - octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ data: { @@ -176,7 +166,7 @@ describe('GithubCredentialsProvider tests', () => { }); it('should throw if the app is suspended', async () => { - octokit.apps.listInstallations.mockResolvedValueOnce({ + octokit.apps.listInstallations.mockResolvedValue({ headers: { etag: '123', }, @@ -193,7 +183,6 @@ describe('GithubCredentialsProvider tests', () => { }, ], } as RestEndpointMethodTypes['apps']['listInstallations']['response']); - octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); await expect( github.getCredentials({ @@ -229,7 +218,7 @@ describe('GithubCredentialsProvider tests', () => { ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); }); - it('should return the configured token if listing installations throws', async () => { + it('should return the configured token if there are no installations', async () => { const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', apps: [ @@ -243,7 +232,9 @@ describe('GithubCredentialsProvider tests', () => { ], token: 'hardcoded_token', }); - octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + octokit.apps.listInstallations.mockResolvedValue(({ + data: [], + } as unknown) as RestEndpointMethodTypes['apps']['listInstallations']['response']); await expect( githubProvider.getCredentials({ diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 4bc6411870..899b233195 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -66,7 +66,6 @@ const HEADERS = { class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; - private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; private readonly cache = new Cache(); constructor(config: GithubAppConfig, baseUrl?: string) { @@ -121,22 +120,15 @@ class GithubAppManager { }); } + getInstallations(): Promise< + RestEndpointMethodTypes['apps']['listInstallations']['response']['data'] + > { + return this.appClient.paginate(this.appClient.apps.listInstallations); + } + private async getInstallationData(owner: string): Promise { - // List all installations using the last used etag. - // Return cached InstallationData if error with status 304 is thrown. - try { - this.installations = await this.appClient.apps.listInstallations({ - headers: { - 'If-None-Match': this.installations?.headers.etag, - Accept: HEADERS.Accept, - }, - }); - } catch (error) { - if (error.status !== 304) { - throw error; - } - } - const installation = this.installations?.data.find( + const allInstallations = await this.getInstallations(); + const installation = allInstallations.find( inst => inst.account?.login === owner, ); if (installation) { @@ -163,6 +155,20 @@ export class GithubAppCredentialsMux { config.apps?.map(ac => new GithubAppManager(ac, config.apiBaseUrl)) ?? []; } + async getAllInstallations(): Promise< + RestEndpointMethodTypes['apps']['listInstallations']['response']['data'] + > { + if (!this.apps.length) { + return []; + } + + const installs = await Promise.all( + this.apps.map(app => app.getInstallations()), + ); + + return installs.flat(); + } + async getAppToken(owner: string, repo?: string): Promise { if (this.apps.length === 0) { return undefined; diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index d3caf19713..9247c4ff8e 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -20,6 +20,9 @@ export { } from './config'; export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; -export { GithubCredentialsProvider } from './GithubCredentialsProvider'; +export { + GithubAppCredentialsMux, + GithubCredentialsProvider, +} from './GithubCredentialsProvider'; export type { GithubCredentialType } from './GithubCredentialsProvider'; export { GitHubIntegration } from './GitHubIntegration'; diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index 20c282c5a3..b1e74035f1 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -44,8 +44,12 @@ export function basicIntegrations( return integrations; }, byUrl(url: string | URL): T | undefined { - const parsed = typeof url === 'string' ? new URL(url) : url; - return integrations.find(i => getHost(i) === parsed.hostname); + try { + const parsed = typeof url === 'string' ? new URL(url) : url; + return integrations.find(i => getHost(i) === parsed.hostname); + } catch { + return undefined; + } }, byHost(host: string): T | undefined { return integrations.find(i => getHost(i) === host); diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 80d48f9dd7..c39060a934 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/techdocs-common +## 0.6.6 + +### Patch Changes + +- ab5cc376f: Use new utilities from `@backstage/backend-common` for safely resolving child paths +- b47fc34bc: Update "service catalog" references to "software catalog" +- Updated dependencies + - @backstage/backend-common@0.8.4 + - @backstage/integration@0.5.7 + ## 0.6.5 ### Patch Changes diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 3fff030eee..d25f55bf3a 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -12,17 +12,17 @@ import { EntityName } from '@backstage/catalog-model'; import express from 'express'; import { GitHubIntegrationConfig } from '@backstage/integration'; import { GitLabIntegrationConfig } from '@backstage/integration'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; // @public (undocumented) -export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger) => Promise; +export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger_2) => Promise; // @public (undocumented) export class CommonGitPreparer implements PreparerBase { - constructor(config: Config, logger: Logger); + constructor(config: Config, logger: Logger_2); // (undocumented) prepare(entity: Entity, options?: { etag?: string; @@ -31,7 +31,7 @@ export class CommonGitPreparer implements PreparerBase { // @public (undocumented) export class DirectoryPreparer implements PreparerBase { - constructor(config: Config, logger: Logger, reader: UrlReader); + constructor(config: Config, logger: Logger_2, reader: UrlReader); // (undocumented) prepare(entity: Entity): Promise; } @@ -51,7 +51,7 @@ export type GeneratorBuilder = { export class Generators implements GeneratorBuilder { // (undocumented) static fromConfig(config: Config, { logger, containerRunner, }: { - logger: Logger; + logger: Logger_2; containerRunner: ContainerRunner; }): Promise; // (undocumented) @@ -69,7 +69,7 @@ export const getDefaultBranch: (repositoryUrl: string, config: Config) => Promis // @public (undocumented) export const getDocFilesFromRepository: (reader: UrlReader, entity: Entity, opts?: { etag?: string | undefined; - logger?: Logger | undefined; + logger?: Logger_2 | undefined; } | undefined) => Promise; // @public (undocumented) @@ -88,7 +88,7 @@ export const getGitRepositoryTempFolder: (repositoryUrl: string, config: Config) export function getGitRepoType(url: string): string; // @public (undocumented) -export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger) => Promise; +export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger_2) => Promise; // @public (undocumented) export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation; @@ -108,7 +108,7 @@ export const parseReferenceAnnotation: (annotationName: string, entity: Entity) // @public (undocumented) export type PreparerBase = { prepare(entity: Entity, options?: { - logger?: Logger; + logger?: Logger_2; etag?: string; }): Promise; }; @@ -153,7 +153,7 @@ export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azu // @public (undocumented) export class TechdocsGenerator implements GeneratorBase { constructor({ logger, containerRunner, config, }: { - logger: Logger; + logger: Logger_2; containerRunner: ContainerRunner; config: Config; }); @@ -170,7 +170,7 @@ export type TechDocsMetadata = { // @public (undocumented) export class UrlPreparer implements PreparerBase { - constructor(reader: UrlReader, logger: Logger); + constructor(reader: UrlReader, logger: Logger_2); // (undocumented) prepare(entity: Entity, options?: { etag?: string; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 0a545ce805..7cb0e37174 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.6.5", + "version": "0.6.6", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,17 +38,17 @@ "dependencies": { "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.8.2", + "@backstage/backend-common": "^0.8.4", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@google-cloud/storage": "^5.6.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "fs-extra": "^9.0.1", + "fs-extra": "^10.0.0", "git-url-parse": "^11.4.4", "js-yaml": "^4.0.0", "json5": "^2.1.3", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^4.0.0", diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 718ee9f924..086377b4ef 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -15,6 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; +import { isChildPath } from '@backstage/backend-common'; import { spawn } from 'child_process'; import fs from 'fs-extra'; import yaml, { DEFAULT_SCHEMA, Type } from 'js-yaml'; @@ -23,7 +24,6 @@ import { Logger } from 'winston'; import { ParsedLocationAnnotation } from '../../helpers'; import { RemoteProtocol } from '../prepare/types'; import { SupportedGeneratorKey } from './types'; -import { resolve as resolvePath } from 'path'; // TODO: Implement proper support for more generators. export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { @@ -178,10 +178,7 @@ export const validateMkdocsYaml = async ( schema: MKDOCS_SCHEMA, }); - if ( - mkdocsYml.docs_dir && - !resolvePath(inputDir, mkdocsYml.docs_dir).startsWith(inputDir) - ) { + if (mkdocsYml.docs_dir && !isChildPath(inputDir, mkdocsYml.docs_dir)) { throw new Error( `docs_dir configuration value in mkdocs can't be an absolute directory or start with ../ for security reasons. Use relative paths instead which are resolved relative to your mkdocs.yml file location.`, diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 18944b5605..6be8539aa9 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -156,7 +156,7 @@ export class OpenStackSwiftPublish implements PublisherBase { const uploadFile = limiter( () => new Promise((res, rej) => { - const readStream = fs.createReadStream(filePath, 'utf8'); + const readStream = fs.createReadStream(filePath); const writeStream = this.storageClient.upload(params); diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index b8fa785122..fbec69278e 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -31,7 +31,7 @@ "dependencies": { "@asyncapi/react-component": "^0.23.0", "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog": "^0.6.4", "@backstage/plugin-catalog-react": "^0.2.4", @@ -51,8 +51,8 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index 4f59102265..93aba5596f 100644 --- a/plugins/app-backend/api-report.md +++ b/plugins/app-backend/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -18,7 +18,7 @@ export interface RouterOptions { config: Config; disableConfigInjection?: boolean; // (undocumented) - logger: Logger; + logger: Logger_2; staticFallbackHandler?: express.Handler; } diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 3b79162624..384d44d82a 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -35,7 +35,7 @@ "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 07662d46ff..822ec104e0 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-auth-backend +## 0.3.15 + +### Patch Changes + +- 6ca29b66c: Unbreak `.well-known` OIDC routes +- 72574ac4d: Show better error message when configs defined under auth.providers. are undefined. +- Updated dependencies + - @backstage/backend-common@0.8.4 + - @backstage/catalog-client@0.3.15 + ## 0.3.14 ### Patch Changes diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 6ae878accf..9f9a8d18ef 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -9,7 +9,7 @@ import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { JSONWebKey } from 'jose'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; @@ -23,7 +23,7 @@ export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; config: Config; - logger: Logger; + logger: Logger_2; tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; @@ -206,7 +206,7 @@ export interface RouterOptions { // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) - logger: Logger; + logger: Logger_2; // (undocumented) providerFactories?: ProviderFactories; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index d2b15a9d2d..085590c8fa 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.3.14", + "version": "0.3.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.3", - "@backstage/catalog-client": "^0.3.14", + "@backstage/backend-common": "^0.8.4", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", @@ -44,7 +44,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "got": "^11.5.2", "helmet": "^4.0.0", "jose": "^1.27.1", @@ -68,7 +68,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 79111df09e..cebc5332e2 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.2.4", @@ -35,8 +35,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 3d970a3e8b..450ddaca85 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -38,8 +38,8 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md new file mode 100644 index 0000000000..3806a6b5d3 --- /dev/null +++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md @@ -0,0 +1,8 @@ +# @backstage/plugin-catalog-backend-module-ldap + +## 0.1.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.11.0 diff --git a/plugins/catalog-backend-module-ldap/api-report.md b/plugins/catalog-backend-module-ldap/api-report.md index d5e9d731ea..446dc2df52 100644 --- a/plugins/catalog-backend-module-ldap/api-report.md +++ b/plugins/catalog-backend-module-ldap/api-report.md @@ -11,11 +11,20 @@ import { Config } from '@backstage/config'; import { GroupEntity } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; import { LocationSpec } from '@backstage/catalog-model'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { SearchEntry } from 'ldapjs'; import { SearchOptions } from 'ldapjs'; import { UserEntity } from '@backstage/catalog-model'; +// @public (undocumented) +export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise; + +// @public (undocumented) +export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise; + +// @public +export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise; + // @public export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn"; @@ -29,7 +38,7 @@ export const LDAP_UUID_ANNOTATION = "backstage.io/ldap-uuid"; export class LdapClient { constructor(client: Client); // (undocumented) - static create(logger: Logger, target: string, bind?: BindConfig): Promise; + static create(logger: Logger_2, target: string, bind?: BindConfig): Promise; getRootDSE(): Promise; getVendor(): Promise; search(dn: string, options: SearchOptions): Promise; @@ -39,15 +48,19 @@ export class LdapClient { export class LdapOrgReaderProcessor implements CatalogProcessor { constructor(options: { providers: LdapProviderConfig[]; - logger: Logger; + logger: Logger_2; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; }); // (undocumented) static fromConfig(config: Config, options: { - logger: Logger; + logger: Logger_2; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; }): LdapOrgReaderProcessor; // (undocumented) readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise; -} + } // @public export type LdapProviderConfig = { @@ -57,15 +70,25 @@ export type LdapProviderConfig = { groups: GroupConfig; }; +// @public +export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void; + // @public export function readLdapConfig(config: Config): LdapProviderConfig[]; // @public -export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{ +export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: { + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + logger: Logger_2; +}): Promise<{ users: UserEntity[]; groups: GroupEntity[]; }>; +// @public +export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise; + // (No @packageDocumentation comment for this package) diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index d8a5545368..4d840b7f48 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-ldap", - "version": "0.1.0", + "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,17 +30,17 @@ "dependencies": { "@backstage/catalog-model": "^0.8.3", "@backstage/config": "^0.1.5", - "@backstage/plugin-catalog-backend": "^0.10.2", + "@backstage/plugin-catalog-backend": "^0.11.0", "@types/ldapjs": "^1.0.10", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.1", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.13", "@types/lodash": "^4.14.151", - "msw": "^0.21.2" + "msw": "^0.29.0" }, "files": [ "dist", diff --git a/plugins/catalog-backend-module-ldap/src/ldap/index.ts b/plugins/catalog-backend-module-ldap/src/ldap/index.ts index 194a75ac60..a9f127a3a7 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/index.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/index.ts @@ -15,6 +15,7 @@ */ export { LdapClient } from './client'; +export { mapStringAttr } from './util'; export { readLdapConfig } from './config'; export type { LdapProviderConfig } from './config'; export { @@ -22,4 +23,9 @@ export { LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION, } from './constants'; -export { readLdapOrg } from './read'; +export { + defaultGroupTransformer, + defaultUserTransformer, + readLdapOrg, +} from './read'; +export type { GroupTransformer, UserTransformer } from './types'; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.ts index a662b36d87..f3ee09d32d 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -26,74 +26,97 @@ import { LDAP_UUID_ANNOTATION, } from './constants'; import { LdapVendor } from './vendors'; +import { Logger } from 'winston'; +import { GroupTransformer, UserTransformer } from './types'; +import { mapStringAttr } from './util'; + +export async function defaultUserTransformer( + vendor: LdapVendor, + config: UserConfig, + entry: SearchEntry, +): Promise { + const { set, map } = config; + + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: '', + annotations: {}, + }, + spec: { + profile: {}, + memberOf: [], + }, + }; + + if (set) { + for (const [path, value] of Object.entries(set)) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(entry, vendor, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(entry, vendor, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(entry, vendor, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(entry, vendor, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(entry, vendor, map.picture, v => { + entity.spec.profile!.picture = v; + }); + + return entity; +} /** * Reads users out of an LDAP provider. * * @param client The LDAP client * @param config The user data configuration + * @param opts */ export async function readLdapUsers( client: LdapClient, config: UserConfig, + opts?: { transformer?: UserTransformer }, ): Promise<{ users: UserEntity[]; // With all relations empty userMemberOf: Map>; // DN -> DN or UUID of groups }> { - const { dn, options, set, map } = config; + const { dn, options, map } = config; const vendor = await client.getVendor(); - const entries = await client.search(dn, options); - const entities: UserEntity[] = []; const userMemberOf: Map> = new Map(); - for (const entry of entries) { - const entity: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: '', - annotations: {}, - }, - spec: { - profile: {}, - memberOf: [], - }, - }; + const transformer = opts?.transformer ?? defaultUserTransformer; - if (set) { - for (const [path, value] of Object.entries(set)) { - lodashSet(entity, path, value); - } + const entries = await client.search(dn, options); + + for (const user of entries) { + const entity = await transformer(vendor, config, user); + + if (!entity) { + continue; } - mapStringAttr(entry, vendor, map.name, v => { - entity.metadata.name = v; - }); - mapStringAttr(entry, vendor, map.description, v => { - entity.metadata.description = v; - }); - mapStringAttr(entry, vendor, map.rdn, v => { - entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { - entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { - entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, map.displayName, v => { - entity.spec.profile!.displayName = v; - }); - mapStringAttr(entry, vendor, map.email, v => { - entity.spec.profile!.email = v; - }); - mapStringAttr(entry, vendor, map.picture, v => { - entity.spec.profile!.picture = v; - }); - - mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { + mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { ensureItems(userMemberOf, myDn, vs); }); @@ -103,82 +126,103 @@ export async function readLdapUsers( return { users: entities, userMemberOf }; } +export async function defaultGroupTransformer( + vendor: LdapVendor, + config: GroupConfig, + entry: SearchEntry, +): Promise { + const { set, map } = config; + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: '', + annotations: {}, + }, + spec: { + type: 'unknown', + profile: {}, + children: [], + }, + }; + + if (set) { + for (const [path, value] of Object.entries(set)) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(entry, vendor, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(entry, vendor, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(entry, vendor, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, map.type, v => { + entity.spec.type = v; + }); + mapStringAttr(entry, vendor, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(entry, vendor, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(entry, vendor, map.picture, v => { + entity.spec.profile!.picture = v; + }); + + return entity; +} + /** * Reads groups out of an LDAP provider. * * @param client The LDAP client * @param config The group data configuration + * @param opts */ export async function readLdapGroups( client: LdapClient, config: GroupConfig, + opts?: { + transformer?: GroupTransformer; + }, ): Promise<{ groups: GroupEntity[]; // With all relations empty groupMemberOf: Map>; // DN -> DN or UUID of groups groupMember: Map>; // DN -> DN or UUID of groups & users }> { - const { dn, options, set, map } = config; - const vendor = await client.getVendor(); - - const entries = await client.search(dn, options); - const groups: GroupEntity[] = []; const groupMemberOf: Map> = new Map(); const groupMember: Map> = new Map(); - for (const entry of entries) { - const entity: GroupEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: '', - annotations: {}, - }, - spec: { - type: 'unknown', - profile: {}, - children: [], - }, - }; + const { dn, map, options } = config; + const vendor = await client.getVendor(); - if (set) { - for (const [path, value] of Object.entries(set)) { - lodashSet(entity, path, value); - } + const transformer = opts?.transformer ?? defaultGroupTransformer; + + const entries = await client.search(dn, options); + + for (const group of entries) { + const entity = await transformer(vendor, config, group); + + if (!entity) { + continue; } - mapStringAttr(entry, vendor, map.name, v => { - entity.metadata.name = v; - }); - mapStringAttr(entry, vendor, map.description, v => { - entity.metadata.description = v; - }); - mapStringAttr(entry, vendor, map.rdn, v => { - entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { - entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { - entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, map.type, v => { - entity.spec.type = v; - }); - mapStringAttr(entry, vendor, map.displayName, v => { - entity.spec.profile!.displayName = v; - }); - mapStringAttr(entry, vendor, map.email, v => { - entity.spec.profile!.email = v; - }); - mapStringAttr(entry, vendor, map.picture, v => { - entity.spec.profile!.picture = v; - }); - - mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { + mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); }); - mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { + mapReferencesAttr(group, vendor, map.members, (myDn, vs) => { ensureItems(groupMember, myDn, vs); }); @@ -199,22 +243,30 @@ export async function readLdapGroups( * with all relations etc filled in. * * @param client The LDAP client - * @param logger A logger instance * @param userConfig The user data configuration * @param groupConfig The group data configuration + * @param options */ export async function readLdapOrg( client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, + options: { + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + logger: Logger; + }, ): Promise<{ users: UserEntity[]; groups: GroupEntity[]; }> { - const { users, userMemberOf } = await readLdapUsers(client, userConfig); + const { users, userMemberOf } = await readLdapUsers(client, userConfig, { + transformer: options?.userTransformer, + }); const { groups, groupMemberOf, groupMember } = await readLdapGroups( client, groupConfig, + { transformer: options?.groupTransformer }, ); resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember); @@ -228,21 +280,6 @@ export async function readLdapOrg( // Helpers // -// Maps a single-valued attribute to a consumer -function mapStringAttr( - entry: SearchEntry, - vendor: LdapVendor, - attributeName: string | undefined, - setter: (value: string) => void, -) { - if (attributeName) { - const values = vendor.decodeStringAttribute(entry, attributeName); - if (values && values.length === 1) { - setter(values[0]); - } - } -} - // Maps a multi-valued attribute of references to other objects, to a consumer function mapReferencesAttr( entry: SearchEntry, diff --git a/plugins/catalog-backend-module-ldap/src/ldap/types.ts b/plugins/catalog-backend-module-ldap/src/ldap/types.ts new file mode 100644 index 0000000000..e32e4c5b42 --- /dev/null +++ b/plugins/catalog-backend-module-ldap/src/ldap/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { SearchEntry } from 'ldapjs'; +import { LdapVendor } from './vendors'; +import { GroupConfig, UserConfig } from './config'; + +/** + * Customize the ingested User entity + * + * @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes + * @param config The User specific config used by the default transformer. + * @param user The found LDAP entry in its source format. This is the entry that you want to transform + * @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog + */ +export type UserTransformer = ( + vendor: LdapVendor, + config: UserConfig, + user: SearchEntry, +) => Promise; + +/** + * Customize the ingested Group entity + * + * @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes + * @param config The Group specific config used by the default transformer. + * @param group The found LDAP entry in its source format. This is the entry that you want to transform + * @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog + */ +export type GroupTransformer = ( + vendor: LdapVendor, + config: GroupConfig, + group: SearchEntry, +) => Promise; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/util.ts b/plugins/catalog-backend-module-ldap/src/ldap/util.ts index 74dd84cdc9..21ee40f52c 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/util.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/util.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Error as LDAPError } from 'ldapjs'; +import { Error as LDAPError, SearchEntry } from 'ldapjs'; +import { LdapVendor } from './vendors'; /** * Builds a string form of an LDAP Error structure. @@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs'; export function errorString(error: LDAPError) { return `${error.code} ${error.name}: ${error.message}`; } + +/** + * Maps a single-valued attribute to a consumer + * + * @param entry The LDAP source entry + * @param vendor The LDAP vendor + * @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored. + * @param setter The function to be called with the decoded attribute from the source entry + */ +export function mapStringAttr( + entry: SearchEntry, + vendor: LdapVendor, + attributeName: string | undefined, + setter: (value: string) => void, +) { + if (attributeName) { + const values = vendor.decodeStringAttribute(entry, attributeName); + if (values && values.length === 1) { + setter(values[0]); + } + } +} diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts index 79c0ed6964..f87afc38b9 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts @@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { + GroupTransformer, LdapClient, LdapProviderConfig, readLdapConfig, readLdapOrg, + UserTransformer, } from '../ldap'; import { CatalogProcessor, @@ -35,8 +37,17 @@ import { export class LdapOrgReaderProcessor implements CatalogProcessor { private readonly providers: LdapProviderConfig[]; private readonly logger: Logger; + private readonly groupTransformer?: GroupTransformer; + private readonly userTransformer?: UserTransformer; - static fromConfig(config: Config, options: { logger: Logger }) { + static fromConfig( + config: Config, + options: { + logger: Logger; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + }, + ) { const c = config.getOptionalConfig('catalog.processors.ldapOrg'); return new LdapOrgReaderProcessor({ ...options, @@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { }); } - constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) { + constructor(options: { + providers: LdapProviderConfig[]; + logger: Logger; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + }) { this.providers = options.providers; this.logger = options.logger; + this.groupTransformer = options.groupTransformer; + this.userTransformer = options.userTransformer; } async readLocation( @@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { client, provider.users, provider.groups, + { + groupTransformer: this.groupTransformer, + userTransformer: this.userTransformer, + logger: this.logger, + }, ); const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md index e85601ab7c..6de8b0ec07 100644 --- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md +++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog-backend-module-msgraph +## 0.2.0 + +### Minor Changes + +- 115473c08: Handle error gracefully if failure occurs while loading photos using Microsoft Graph API. + + This includes a breaking change: you now have to pass the `options` object to `readMicrosoftGraphUsers` and `readMicrosoftGraphOrg`. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.11.0 + ## 0.1.1 ### Patch Changes diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 702601b469..c755514b48 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -9,7 +9,7 @@ import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; import { GroupEntity } from '@backstage/catalog-model'; import { LocationSpec } from '@backstage/catalog-model'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import * as msal from '@azure/msal-node'; import { UserEntity } from '@backstage/catalog-model'; @@ -70,12 +70,12 @@ export class MicrosoftGraphClient { export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { constructor(options: { providers: MicrosoftGraphProviderConfig[]; - logger: Logger; + logger: Logger_2; groupTransformer?: GroupTransformer; }); // (undocumented) static fromConfig(config: Config, options: { - logger: Logger; + logger: Logger_2; groupTransformer?: GroupTransformer; }): MicrosoftGraphOrgReaderProcessor; // (undocumented) @@ -103,10 +103,11 @@ export type OrganizationTransformer = (organization: MicrosoftGraph.Organization export function readMicrosoftGraphConfig(config: Config): MicrosoftGraphProviderConfig[]; // @public (undocumented) -export function readMicrosoftGraphOrg(client: MicrosoftGraphClient, tenantId: string, options?: { +export function readMicrosoftGraphOrg(client: MicrosoftGraphClient, tenantId: string, options: { userFilter?: string; groupFilter?: string; groupTransformer?: GroupTransformer; + logger: Logger_2; }): Promise<{ users: UserEntity[]; groups: GroupEntity[]; diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index f37b954706..1c1886d802 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-msgraph", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "@azure/msal-node": "^1.1.0", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/plugin-catalog-backend": "^0.10.4", + "@backstage/plugin-catalog-backend": "^0.11.0", "@microsoft/microsoft-graph-types": "^1.25.0", "cross-fetch": "^3.0.6", "lodash": "^4.17.15", @@ -40,7 +40,8 @@ "qs": "^6.9.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/backend-common": "^0.8.4", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.14", "@types/lodash": "^4.14.151", "msw": "^0.29.0" diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts index b1179c20df..929d01e128 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts @@ -23,6 +23,7 @@ import { readMicrosoftGraphUsers, resolveRelations, } from './read'; +import { getVoidLogger } from '@backstage/backend-common'; function user(data: Partial): UserEntity { return merge( @@ -84,6 +85,7 @@ describe('read microsoft graph', () => { const { users } = await readMicrosoftGraphUsers(client, { userFilter: 'accountEnabled eq true', + logger: getVoidLogger(), }); expect(users).toEqual([ diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index 70c403d0b7..a9e2465ad7 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -33,6 +33,7 @@ import { OrganizationTransformer, UserTransformer, } from './types'; +import { Logger } from 'winston'; export async function defaultUserTransformer( user: MicrosoftGraph.User, @@ -75,7 +76,11 @@ export async function defaultUserTransformer( export async function readMicrosoftGraphUsers( client: MicrosoftGraphClient, - options?: { userFilter?: string; transformer?: UserTransformer }, + options: { + userFilter?: string; + transformer?: UserTransformer; + logger: Logger; + }, ): Promise<{ users: UserEntity[]; // With all relations empty }> { @@ -86,17 +91,22 @@ export async function readMicrosoftGraphUsers( const promises: Promise[] = []; for await (const user of client.getUsers({ - filter: options?.userFilter, + filter: options.userFilter, })) { // Process all users in parallel, otherwise it can take quite some time promises.push( limiter(async () => { - const userPhoto = await client.getUserPhotoWithSizeLimit( - user.id!, - // We are limiting the photo size, as users with full resolution photos - // can make the Backstage API slow - 120, - ); + let userPhoto; + try { + userPhoto = await client.getUserPhotoWithSizeLimit( + user.id!, + // We are limiting the photo size, as users with full resolution photos + // can make the Backstage API slow + 120, + ); + } catch (e) { + options.logger.warn(`Unable to load photo for ${user.id}`); + } const entity = await transformer(user, userPhoto); @@ -371,14 +381,16 @@ export function resolveRelations( export async function readMicrosoftGraphOrg( client: MicrosoftGraphClient, tenantId: string, - options?: { + options: { userFilter?: string; groupFilter?: string; groupTransformer?: GroupTransformer; + logger: Logger; }, ): Promise<{ users: UserEntity[]; groups: GroupEntity[] }> { const { users } = await readMicrosoftGraphUsers(client, { - userFilter: options?.userFilter, + userFilter: options.userFilter, + logger: options.logger, }); const { groups, diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts index d86584a9fd..88240db618 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts @@ -90,6 +90,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { userFilter: provider.userFilter, groupFilter: provider.groupFilter, groupTransformer: this.groupTransformer, + logger: this.logger, }, ); diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 0f2ca0a54d..174f279b11 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,95 @@ # @backstage/plugin-catalog-backend +## 0.11.0 + +### Minor Changes + +- 45af985df: Handle entity name conflicts in a deterministic way and avoid crashes due to naming conflicts at startup. + + This is a breaking change for the database and entity provider interfaces of the new catalog. The interfaces with breaking changes are `EntityProvider` and `ProcessingDatabase`, and while it's unlikely that these interfaces have much usage yet, a migration guide is provided below. + + The breaking change to the `EntityProvider` interface lies within the items passed in the `EntityProviderMutation` type. Rather than passing along entities directly, they are now wrapped up in a `DeferredEntity` type, which is a tuple of an `entity` and a `locationKey`. The `entity` houses the entity as it was passed on before, while the `locationKey` is a new concept that is used for conflict resolution within the catalog. + + The `locationKey` is an opaque string that should be unique for each location that an entity could be located at, and undefined if the entity does not have a fixed location. In practice it should be set to the serialized location reference if the entity is stored in Git, for example `https://github.com/backstage/backstage/blob/master/catalog-info.yaml`. A conflict between two entity definitions happen when they have the same entity reference, i.e. kind, namespace, and name. In the event of a conflict the location key will be used according to the following rules to resolve the conflict: + + - If the entity is already present in the database but does not have a location key set, the new entity wins and will override the existing one. + - If the entity is already present in the database the new entity will only win if the location keys of the existing and new entity are the same. + - If the entity is not already present, insert the entity into the database along with the provided location key. + + The breaking change to the `ProcessingDatabase` is similar to the one for the entity provider, as it reflects the switch from `Entity` to `DeferredEntity` in the `ReplaceUnprocessedEntitiesOptions`. In addition, the `addUnprocessedEntities` method has been removed from the `ProcessingDatabase` interface, and the `RefreshStateItem` and `UpdateProcessedEntityOptions` types have received a new optional `locationKey` property. + +- 8e533f92c: Move `LdapOrgReaderProcessor` from `@backstage/plugin-catalog-backend` + to `@backstage/plugin-catalog-backend-module-ldap`. + + The `LdapOrgReaderProcessor` isn't registered by default anymore, if + you want to continue using it you have to register it manually at the catalog + builder: + + 1. Add dependency to `@backstage/plugin-catalog-backend-module-ldap` to the `package.json` of your backend. + 2. Add the processor to the catalog builder: + + ```typescript + // packages/backend/src/plugins/catalog.ts + builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(config, { + logger, + }), + ); + ``` + + For more configuration details, see the [README of the `@backstage/plugin-catalog-backend-module-ldap` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md). + +### Patch Changes + +- 22a60518c: Support ingesting multiple GitHub organizations via a new `GithubMultiOrgReaderProcessor`. + + This new processor handles namespacing created groups according to the org of the associated GitHub team to prevent potential name clashes between organizations. Be aware that this processor is considered alpha and may not be compatible with future org structures in the catalog. + + NOTE: This processor only fully supports auth via GitHub Apps + + To install this processor, import and add it as follows: + + ```typescript + // Typically in packages/backend/src/plugins/catalog.ts + import { GithubMultiOrgReaderProcessor } from '@backstage/plugin-catalog-backend'; + // ... + export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + builder.addProcessor( + GithubMultiOrgReaderProcessor.fromConfig(env.config, { + logger: env.logger, + }), + ); + // ... + } + ``` + + Configure in your `app-config.yaml` by pointing to your GitHub instance and optionally list which GitHub organizations you wish to import. You can also configure what namespace you want to set for teams from each org. If unspecified, the org name will be used as the namespace. If no organizations are listed, by default this processor will import from all organizations accessible by all configured GitHub Apps: + + ```yaml + catalog: + locations: + - type: github-multi-org + target: https://github.myorg.com + + processors: + githubMultiOrg: + orgs: + - name: fooOrg + groupNamespace: foo + - name: barOrg + groupNamespace: bar + - name: awesomeOrg + - name: anotherOrg + ``` + +- d408af872: Only return the selected fields from the new catalog. +- aa2b15d9d: Ensure that emitted relations are deduplicated +- Updated dependencies + - @backstage/backend-common@0.8.4 + - @backstage/integration@0.5.7 + - @backstage/catalog-client@0.3.15 + ## 0.10.4 ### Patch Changes diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index e00e532622..7da9cad139 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -145,6 +145,27 @@ export interface Config { }>; }; + /** + * GithubMultiOrgReaderProcessor configuration + */ + githubMultiOrg?: { + /** + * The configuration parameters for each GitHub org to process. + */ + orgs: Array<{ + /** + * The name of the GitHub org to process. + */ + name: string; + /** + * The namespace of the group created for this org. + * + * Defaults to org name if omitted. + */ + groupNamespace?: string; + }>; + }; + /** * AwsOrganizationCloudAccountProcessor configuration */ diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/catalog-backend/migrations/20210622104022_refresh_state_location_key.js similarity index 52% rename from plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts rename to plugins/catalog-backend/migrations/20210622104022_refresh_state_location_key.js index 1b6b4380f7..37ab5e12cb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/catalog-backend/migrations/20210622104022_refresh_state_location_key.js @@ -13,28 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; -export type PreparerOptions = { - /** - * Full URL to the directory containg template data - */ - url: string; - /** - * The workspace path that will eventually be the the root of the new repo - */ - workspacePath: string; - logger: Logger; +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('refresh_state', table => { + table + .text('location_key') + .nullable() + .comment( + 'An opaque key that uniquely identifies the location of an entity in order to support conflict resolution', + ); + }); }; -export interface PreparerBase { - /** - * Prepare a directory with contents from the remote location - */ - prepare(opts: PreparerOptions): Promise; -} - -export type PreparerBuilder = { - register(host: string, preparer: PreparerBase): void; - get(url: string): PreparerBase; +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('refresh_state', table => { + table.dropColumn('location_key'); + }); }; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 144b30306a..13cd430d01 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.10.4", + "version": "0.11.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.3", - "@backstage/catalog-client": "^0.3.14", + "@backstage/backend-common": "^0.8.4", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@backstage/plugin-search-backend-node": "^0.2.2", "@backstage/search-common": "^0.1.2", "@octokit/graphql": "^4.5.8", @@ -46,7 +46,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "fast-json-stable-stringify": "^2.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "git-url-parse": "^11.4.4", "glob": "^7.1.6", "knex": "^0.95.1", @@ -62,7 +62,7 @@ }, "devDependencies": { "@backstage/backend-test-utils": "^0.1.3", - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.14", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", @@ -71,7 +71,7 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.29.0", - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.1", "supertest": "^6.1.3", "wait-for-expect": "^3.0.2" }, diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index bdc6d463c2..33beffed0d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -21,7 +21,7 @@ import { GroupEntity, ResourceEntity, SystemEntity, - TemplateEntity, + TemplateEntityV1beta2, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -522,22 +522,13 @@ describe('BuiltinKindsEntityProcessor', () => { }); }); it('generates relations for template entities', async () => { - const entity: TemplateEntity = { - apiVersion: 'backstage.io/v1alpha1', + const entity: TemplateEntityV1beta2 = { + apiVersion: 'backstage.io/v1beta2', kind: 'Template', metadata: { name: 'n' }, spec: { - schema: { - properties: { - description: { - title: 'd', - type: 'string', - description: 'des', - }, - }, - }, - templater: 'cookiecutter', - path: '.', + parameters: {}, + steps: [], type: 'service', owner: 'o', }, diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index b8805ab9ba..bcabc00f32 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -46,8 +46,7 @@ import { resourceEntityV1alpha1Validator, SystemEntity, systemEntityV1alpha1Validator, - TemplateEntity, - templateEntityV1alpha1Validator, + TemplateEntityV1beta2, templateEntityV1beta2Validator, UserEntity, userEntityV1alpha1Validator, @@ -62,7 +61,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { resourceEntityV1alpha1Validator, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, - templateEntityV1alpha1Validator, templateEntityV1beta2Validator, userEntityV1alpha1Validator, systemEntityV1alpha1Validator, @@ -136,7 +134,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { * Emit relations for the Template kind */ if (entity.kind === 'Template') { - const template = entity as TemplateEntity; + const template = entity as TemplateEntityV1beta2; doEmit( template.spec.owner, { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubMultiOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubMultiOrgReaderProcessor.ts new file mode 100644 index 0000000000..5223acba4a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubMultiOrgReaderProcessor.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + GithubAppCredentialsMux, + GithubCredentialsProvider, + GitHubIntegrationConfig, + ScmIntegrations, +} from '@backstage/integration'; +import { graphql } from '@octokit/graphql'; +import { Logger } from 'winston'; +import { + getOrganizationTeams, + getOrganizationUsers, + GithubMultiOrgConfig, + readGithubMultiOrgConfig, +} from './github'; +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import { buildOrgHierarchy } from './util/org'; + +/** + * @alpha + * Extracts teams and users out of a multiple GitHub orgs namespaced per org. + * + * Be aware that this processor may not be compatible with future org structures in the catalog. + */ +export class GithubMultiOrgReaderProcessor implements CatalogProcessor { + private readonly integrations: ScmIntegrations; + private readonly orgs: GithubMultiOrgConfig; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const c = config.getOptionalConfig('catalog.processors.githubMultiOrg'); + const integrations = ScmIntegrations.fromConfig(config); + + return new GithubMultiOrgReaderProcessor({ + ...options, + integrations, + orgs: c ? readGithubMultiOrgConfig(c) : [], + }); + } + + constructor(options: { + integrations: ScmIntegrations; + logger: Logger; + orgs: GithubMultiOrgConfig; + }) { + this.integrations = options.integrations; + this.logger = options.logger; + this.orgs = options.orgs; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== 'github-multi-org') { + return false; + } + + const gitHubConfig = this.integrations.github.byUrl(location.target) + ?.config; + if (!gitHubConfig) { + throw new Error( + `There is no GitHub integration that matches ${location.target}. Please add a configuration entry for it under integrations.github`, + ); + } + + const allUsersMap = new Map(); + const baseUrl = new URL(location.target).origin; + const credentialsProvider = GithubCredentialsProvider.create(gitHubConfig); + + const orgsToProcess = this.orgs.length + ? this.orgs + : await this.getAllOrgs(gitHubConfig); + + for (const orgConfig of orgsToProcess) { + try { + const { + headers, + type: tokenType, + } = await credentialsProvider.getCredentials({ + url: `${baseUrl}/${orgConfig.name}`, + }); + const client = graphql.defaults({ + baseUrl: gitHubConfig.apiBaseUrl, + headers, + }); + + const startTimestamp = Date.now(); + this.logger.info( + `Reading GitHub users and teams for org: ${orgConfig.name}`, + ); + const { users } = await getOrganizationUsers( + client, + orgConfig.name, + tokenType, + ); + const { groups, groupMemberUsers } = await getOrganizationTeams( + client, + orgConfig.name, + orgConfig.groupNamespace, + ); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${users.length} GitHub users and ${groups.length} GitHub teams from ${orgConfig.name} in ${duration} seconds`, + ); + + users.forEach(u => { + if (!allUsersMap.has(u.metadata.name)) { + allUsersMap.set(u.metadata.name, u); + } + }); + + for (const [groupName, userNames] of groupMemberUsers.entries()) { + for (const userName of userNames) { + const user = allUsersMap.get(userName); + if (user && !user.spec.memberOf.includes(groupName)) { + user.spec.memberOf.push(groupName); + } + } + } + buildOrgHierarchy(groups); + + for (const group of groups) { + emit(results.entity(location, group)); + } + } catch (e) { + this.logger.error( + `Failed to read GitHub org data for ${orgConfig.name}: ${e}`, + ); + } + } + + const allUsers = Array.from(allUsersMap.values()); + for (const user of allUsers) { + emit(results.entity(location, user)); + } + + return true; + } + + // Note: Does not support usage of PATs + private async getAllOrgs( + gitHubConfig: GitHubIntegrationConfig, + ): Promise { + const githubAppMux = new GithubAppCredentialsMux(gitHubConfig); + const installs = await githubAppMux.getAllInstallations(); + + return installs + .map(install => + install.target_type === 'Organization' && + install.account && + install.account.login + ? { + name: install.account.login, + groupNamespace: install.account.login.toLowerCase(), + } + : undefined, + ) + .filter(Boolean) as GithubMultiOrgConfig; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index 8c06ef419e..1b015d7d93 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -94,12 +94,21 @@ export class PlaceholderProcessor implements CatalogProcessor { return [data, false]; } + const read = async (url: string): Promise => { + if (this.options.reader.readUrl) { + const response = await this.options.reader.readUrl(url); + const buffer = await response.buffer(); + return buffer; + } + return this.options.reader.read(url); + }; + return [ await resolver({ key: resolverKey, value: resolverValue, baseUrl: location.target, - read: this.options.reader.read.bind(this.options.reader), + read, }), true, ]; diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index aff126414f..da7bee12d8 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -101,7 +101,12 @@ export class UrlReaderProcessor implements CatalogProcessor { return Promise.all(output); } - // Otherwise do a plain read + // Otherwise do a plain read, prioritizing readUrl if available + if (this.options.reader.readUrl) { + const data = await this.options.reader.readUrl(location); + return [{ url: location, data: await data.buffer() }]; + } + const data = await this.options.reader.read(location); return [{ url: location, data }]; } diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts index ae4255555a..c6b89efcc9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts @@ -28,6 +28,12 @@ export async function readCodeOwners( ): Promise { const readOwnerLocation = async (path: string): Promise => { const url = `${sourceUrl}${path}`; + + if (reader.readUrl) { + const data = await reader.readUrl(url); + const buffer = await data.buffer(); + return buffer.toString(); + } const data = await reader.read(url); return data.toString(); }; diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts index 2eb033a8fe..7bdc28e6dd 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { readGithubConfig } from './config'; +import { readGithubConfig, readGithubMultiOrgConfig } from './config'; describe('config', () => { describe('readGithubConfig', () => { @@ -76,4 +76,35 @@ describe('config', () => { ).toThrow(/token/); }); }); + + describe('readGithubMultiOrgConfig', () => { + function config(orgs: { name: string; groupNamespace?: string }[]) { + return new ConfigReader({ orgs }); + } + + it('reads org configs', () => { + const output = readGithubMultiOrgConfig( + config([ + { name: 'foo', groupNamespace: 'apple' }, + { name: 'bar', groupNamespace: 'Orange' }, + ]), + ); + + expect(output).toEqual([ + { name: 'foo', groupNamespace: 'apple' }, + { name: 'bar', groupNamespace: 'orange' }, + ]); + }); + + it('defaults groupNamespace to org name if undefined', () => { + const output = readGithubMultiOrgConfig( + config([{ name: 'foo' }, { name: 'bar' }]), + ); + + expect(output).toEqual([ + { name: 'foo', groupNamespace: 'foo' }, + { name: 'bar', groupNamespace: 'bar' }, + ]); + }); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.ts index bd71ab8189..40305ff1f2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.ts @@ -82,3 +82,27 @@ export function readGithubConfig(config: Config): ProviderConfig[] { return providers; } + +/** + * The configuration parameters for a multi-org GitHub processor. + */ +export type GithubMultiOrgConfig = Array<{ + /** + * The name of the GitHub org to process. + */ + name: string; + /** + * The namespace of the group created for this org. + */ + groupNamespace: string; +}>; + +export function readGithubMultiOrgConfig(config: Config): GithubMultiOrgConfig { + const orgConfigs = config.getOptionalConfigArray('orgs') ?? []; + return orgConfigs.map(c => ({ + name: c.getString('name'), + groupNamespace: ( + c.getOptionalString('groupNamespace') ?? c.getString('name') + ).toLowerCase(), + })); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 5ef5ae5faf..11cdf671c3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -72,8 +72,10 @@ describe('github', () => { }); describe('getOrganizationTeams', () => { - it('reads teams', async () => { - const input: QueryResponse = { + let input: QueryResponse; + + beforeEach(() => { + input = { organization: { teams: { pageInfo: { hasNextPage: false }, @@ -98,7 +100,9 @@ describe('github', () => { }, }, }; + }); + it('reads teams', async () => { const output = { groups: [ expect.objectContaining({ @@ -126,6 +130,38 @@ describe('github', () => { await expect(getOrganizationTeams(graphql, 'a')).resolves.toEqual(output); }); + + it('applies namespaces', async () => { + const output = { + groups: [ + expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'team', + namespace: 'foo', + description: 'The one and only team', + }), + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + parent: 'parent', + children: [], + }, + }), + ], + groupMemberUsers: new Map([['foo/team', ['user']]]), + }; + + server.use( + graphqlMsw.query('teams', (_req, res, ctx) => res(ctx.data(input))), + ); + + await expect(getOrganizationTeams(graphql, 'a', 'foo')).resolves.toEqual( + output, + ); + }); }); describe('getTeamMembers', () => { diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts index ab17698e98..7e1ef136eb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -144,6 +144,7 @@ export async function getOrganizationUsers( export async function getOrganizationTeams( client: typeof graphql, org: string, + orgNamespace?: string, ): Promise<{ groups: GroupEntity[]; groupMemberUsers: Map; @@ -189,6 +190,10 @@ export async function getOrganizationTeams( }, }; + if (orgNamespace) { + entity.metadata.namespace = orgNamespace; + } + if (team.description) { entity.metadata.description = team.description; } @@ -203,7 +208,8 @@ export async function getOrganizationTeams( } const memberNames: string[] = []; - groupMemberUsers.set(team.slug, memberNames); + const groupKey = orgNamespace ? `${orgNamespace}/${team.slug}` : team.slug; + groupMemberUsers.set(groupKey, memberNames); if (!team.members.pageInfo.hasNextPage) { // We got all the members in one go, run the fast path diff --git a/plugins/catalog-backend/src/ingestion/processors/github/index.ts b/plugins/catalog-backend/src/ingestion/processors/github/index.ts index 2d75813b6d..7c14e21108 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/index.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -export { readGithubConfig } from './config'; -export type { ProviderConfig } from './config'; +export { readGithubConfig, readGithubMultiOrgConfig } from './config'; +export type { GithubMultiOrgConfig, ProviderConfig } from './config'; export { getOrganizationTeams, getOrganizationUsers, diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 88258e3bb8..6a44ea0a8f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -25,6 +25,7 @@ export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; +export { GithubMultiOrgReaderProcessor } from './GithubMultiOrgReaderProcessor'; export { LocationEntityProcessor } from './LocationEntityProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; diff --git a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts index 37d549c7f2..d177b1aa32 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts @@ -41,26 +41,32 @@ describe('ConfigLocationEntityProvider', () => { expect(mockConnection.applyMutation).toHaveBeenCalledWith({ type: 'full', entities: expect.arrayContaining([ - expect.objectContaining({ - spec: { - target: path.join( - resolvePackagePath('@backstage/plugin-catalog-backend'), - './lols.yaml', - ), - type: 'file', - }, - }), + { + entity: expect.objectContaining({ + spec: { + target: path.join( + resolvePackagePath('@backstage/plugin-catalog-backend'), + './lols.yaml', + ), + type: 'file', + }, + }), + locationKey: expect.stringMatching(/lols\.yaml$/), + }, ]), }); expect(mockConnection.applyMutation).toHaveBeenCalledWith({ type: 'full', entities: expect.arrayContaining([ - expect.objectContaining({ - spec: { - target: 'https://github.com/backstage/backstage', - type: 'url', - }, - }), + { + entity: expect.objectContaining({ + spec: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }), + locationKey: 'url:https://github.com/backstage/backstage', + }, ]), }); }); diff --git a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts index 9218a9c0e1..71ec84dae7 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts @@ -16,6 +16,7 @@ import { Config } from '@backstage/config'; import path from 'path'; +import { getEntityLocationRef } from './processing/util'; import { EntityProvider, EntityProviderConnection } from './types'; import { locationSpecToLocationEntity } from './util'; @@ -37,10 +38,12 @@ export class ConfigLocationEntityProvider implements EntityProvider { const entities = locationConfigs.map(location => { const type = location.getString('type'); const target = location.getString('target'); - return locationSpecToLocationEntity({ + const entity = locationSpecToLocationEntity({ type, target: type === 'file' ? path.resolve(target) : target, }); + const locationKey = getEntityLocationRef(entity); + return { entity, locationKey }; }); await this.connection.applyMutation({ diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index a504de0bf3..0ae20c94b1 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -46,7 +46,7 @@ class Connection implements EntityProviderConnection { const db = this.config.processingDatabase; if (mutation.type === 'full') { - this.check(mutation.entities); + this.check(mutation.entities.map(e => e.entity)); await db.transaction(async tx => { await db.replaceUnprocessedEntities(tx, { sourceKey: this.config.id, @@ -57,8 +57,8 @@ class Connection implements EntityProviderConnection { return; } - this.check(mutation.added); - this.check(mutation.removed); + this.check(mutation.added.map(e => e.entity)); + this.check(mutation.removed.map(e => e.entity)); await db.transaction(async tx => { await db.replaceUnprocessedEntities(tx, { sourceKey: this.config.id, @@ -125,7 +125,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { }, processTask: async item => { try { - const { id, state, unprocessedEntity, entityRef } = item; + const { id, state, unprocessedEntity, entityRef, locationKey } = item; const result = await this.orchestrator.process({ entity: unprocessedEntity, state, @@ -171,6 +171,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { errors: errorsString, relations: result.relations, deferredEntities: result.deferredEntities, + locationKey, }); }); diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts index ae2c4e9a20..f965ec3077 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts @@ -48,11 +48,14 @@ describe('DefaultLocationServiceTest', () => { }, deferredEntities: [ { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'bar', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bar', + }, }, + locationKey: 'file:///tmp/mock.yaml', }, ], relations: [], diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts index ceb51a952e..2820ba14b1 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.ts @@ -20,7 +20,10 @@ import { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { CatalogProcessingOrchestrator } from './processing/types'; +import { + CatalogProcessingOrchestrator, + DeferredEntity, +} from './processing/types'; import { LocationService, LocationStore } from './types'; import { locationSpecToMetadataName } from './util'; @@ -73,7 +76,9 @@ export class DefaultLocationService implements LocationService { target: spec.target, }, }; - const unprocessedEntities: Entity[] = [entity]; + const unprocessedEntities: DeferredEntity[] = [ + { entity, locationKey: `${spec.type}:${spec.target}` }, + ]; const entities: Entity[] = []; const state = new Map(); // ignored while (unprocessedEntities.length) { @@ -82,7 +87,7 @@ export class DefaultLocationService implements LocationService { continue; } const processed = await this.orchestrator.process({ - entity: currentEntity, + entity: currentEntity.entity, state, }); diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts index 762a585754..82b08f2e70 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -113,13 +113,17 @@ describe('DefaultLocationStore', () => { type: 'delta', removed: [], added: expect.arrayContaining([ - expect.objectContaining({ - spec: { - target: - 'https://github.com/backstage/demo/blob/master/catalog-info.yml', - type: 'url', - }, - }), + { + entity: expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + locationKey: + 'url:https://github.com/backstage/demo/blob/master/catalog-info.yml', + }, ]), }); }, @@ -156,15 +160,19 @@ describe('DefaultLocationStore', () => { expect(connection.applyMutation).toHaveBeenCalledWith({ type: 'delta', added: [], - removed: expect.arrayContaining([ - expect.objectContaining({ - spec: { - target: - 'https://github.com/backstage/demo/blob/master/catalog-info.yml', - type: 'url', - }, - }), - ]), + removed: [ + { + entity: expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + locationKey: + 'url:https://github.com/backstage/demo/blob/master/catalog-info.yml', + }, + ], }); }, 60_000, diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 09de77e49f..c9ffe9a682 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -19,6 +19,7 @@ import { ConflictError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; import { DbLocationsRow } from './database/tables'; +import { getEntityLocationRef } from './processing/util'; import { EntityProvider, EntityProviderConnection, @@ -60,10 +61,10 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return inner; }); - + const entity = locationSpecToLocationEntity(location); await this.connection.applyMutation({ type: 'delta', - added: [locationSpecToLocationEntity(location)], + added: [{ entity, locationKey: getEntityLocationRef(entity) }], removed: [], }); @@ -102,11 +103,11 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { await tx('locations').where({ id }).del(); return location; }); - + const entity = locationSpecToLocationEntity(deleted); await this.connection.applyMutation({ type: 'delta', added: [], - removed: [locationSpecToLocationEntity(deleted)], + removed: [{ entity, locationKey: getEntityLocationRef(entity) }], }); } @@ -124,7 +125,8 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { const locations = await this.locations(); const entities = locations.map(location => { - return locationSpecToLocationEntity(location); + const entity = locationSpecToLocationEntity(location); + return { entity, locationKey: getEntityLocationRef(entity) }; }); await this.connection.applyMutation({ diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts index a7be2aef10..f3921602e2 100644 --- a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts @@ -133,8 +133,14 @@ export class NextEntitiesCatalog implements EntitiesCatalog { }; } + const dbResponse = rows.map(e => JSON.parse(e.final_entity!)); + + const entities = dbResponse.map(e => + request?.fields ? request.fields(e) : e, + ); + return { - entities: rows.map(e => JSON.parse(e.final_entity!)), + entities, pageInfo, }; } diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index a0db3b293f..e449e5580c 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -19,6 +19,7 @@ import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; import { Knex } from 'knex'; +import { Logger } from 'winston'; import * as uuid from 'uuid'; import { DatabaseManager } from './DatabaseManager'; import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; @@ -29,12 +30,15 @@ import { } from './tables'; describe('Default Processing Database', () => { - const logger = getVoidLogger(); + const defaultLogger = getVoidLogger(); const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], }); - async function createDatabase(databaseId: TestDatabaseId) { + async function createDatabase( + databaseId: TestDatabaseId, + logger: Logger = defaultLogger, + ) { const knex = await databases.init(databaseId); await DatabaseManager.createDatabase(knex); return { @@ -57,6 +61,123 @@ describe('Default Processing Database', () => { await db('refresh_state').insert(ref); }; + describe('addUprocessedEntities', () => { + function mockEntity(name: string, type: string): Entity { + return { + apiVersion: '1', + kind: 'Component', + metadata: { + name, + }, + spec: { + type, + }, + }; + } + + it.each(databases.eachSupportedId())( + 'updates refresh state with varying location keys, %p', + async databaseId => { + const mockWarn = jest.fn(); + const { db } = await createDatabase(databaseId, ({ + debug: jest.fn(), + warn: mockWarn, + } as unknown) as Logger); + await db.transaction(async tx => { + const knexTx = tx as Knex.Transaction; + + const steps = [ + { + locationKey: undefined, + expectedLocationKey: null, + type: 'a', + expectedType: 'a', + }, + { + locationKey: undefined, + expectedLocationKey: null, + type: 'b', + expectedType: 'b', + }, + { + locationKey: 'x', + expectedLocationKey: 'x', + type: 'c', + expectedType: 'c', + }, + { + locationKey: 'y', + expectedLocationKey: 'x', + type: 'd', + expectedType: 'c', + expectConflict: true, + }, + { + locationKey: undefined, + expectedLocationKey: 'x', + type: 'e', + expectedType: 'c', + expectConflict: true, + }, + { + locationKey: 'x', + expectedLocationKey: 'x', + type: 'f', + expectedType: 'f', + }, + ]; + for (const step of steps) { + mockWarn.mockClear(); + + await db.addUnprocessedEntities(tx, { + sourceKey: 'testing', + entities: [ + { + entity: mockEntity('1', step.type), + locationKey: step.locationKey, + }, + ], + }); + + if (step.expectConflict) { + // eslint-disable-next-line jest/no-conditional-expect + expect(mockWarn).toHaveBeenCalledWith( + expect.stringMatching(/^Detected conflicting entityRef/), + ); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(mockWarn).not.toHaveBeenCalled(); + } + + const entities = await knexTx( + 'refresh_state', + ).select(); + expect(entities).toEqual([ + expect.objectContaining({ + entity_ref: 'component:default/1', + location_key: step.expectedLocationKey, + }), + ]); + const entity = JSON.parse(entities[0].unprocessed_entity) as Entity; + expect(entity.spec?.type).toEqual(step.expectedType); + + await expect( + knexTx( + 'refresh_state_references', + ).select(), + ).resolves.toEqual([ + expect.objectContaining({ + source_key: 'testing', + target_entity_ref: 'component:default/1', + }), + ]); + } + }); + }, + 60_000, + ); + }); + describe('updateProcessedEntity', () => { let id: string; let processedEntity: Entity; @@ -77,7 +198,7 @@ describe('Default Processing Database', () => { }); it.each(databases.eachSupportedId())( - 'fails when there is no processing state for the entity, %p', + 'fails when an entity is processed with a different locationKey, %p', async databaseId => { const { db } = await createDatabase(databaseId); await db.transaction(async tx => { @@ -89,12 +210,55 @@ describe('Default Processing Database', () => { relations: [], deferredEntities: [], }), - ).rejects.toThrow(`Processing state not found for ${id}`); + ).rejects.toThrow( + `Conflicting write of processing result for ${id} with location key 'undefined'`, + ); }); }, 60_000, ); + it.each(databases.eachSupportedId())( + 'fails when the locationKey is different, %p', + async databaseId => { + const options = { + id, + processedEntity, + state: new Map(), + relations: [], + deferredEntities: [], + locationKey: 'key', + errors: "['something broke']", + }; + const { knex, db } = await createDatabase(databaseId); + await insertRefreshStateRow(knex, { + entity_id: id, + entity_ref: 'location:default/fakelocation', + unprocessed_entity: '{}', + processed_entity: '{}', + location_key: 'key', + errors: '[]', + next_update_at: '2021-04-01 13:37:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + await db.transaction(tx => db.updateProcessedEntity(tx, options)); + + const entities = await knex( + 'refresh_state', + ).select(); + expect(entities.length).toBe(1); + + await db.transaction(tx => + expect( + db.updateProcessedEntity(tx, { ...options, locationKey: 'fail' }), + ).rejects.toThrow( + `Conflicting write of processing result for ${id} with location key 'fail'`, + ), + ); + }, + 60_000, + ); + it.each(databases.eachSupportedId())( 'updates the refresh state entry with the cache, processed entity and errors, %p', async databaseId => { @@ -119,6 +283,7 @@ describe('Default Processing Database', () => { state, relations: [], deferredEntities: [], + locationKey: 'key', errors: "['something broke']", }), ); @@ -132,6 +297,7 @@ describe('Default Processing Database', () => { ); expect(entities[0].cache).toEqual(JSON.stringify(state)); expect(entities[0].errors).toEqual("['something broke']"); + expect(entities[0].location_key).toEqual('key'); }, 60_000, ); @@ -206,11 +372,14 @@ describe('Default Processing Database', () => { const deferredEntities = [ { - apiVersion: '1', - kind: 'Location', - metadata: { - name: 'next', + entity: { + apiVersion: '1', + kind: 'Location', + metadata: { + name: 'next', + }, }, + locationKey: 'mock', }, ]; @@ -227,7 +396,7 @@ describe('Default Processing Database', () => { const refreshStateEntries = await knex( 'refresh_state', ) - .where({ entity_ref: stringifyEntityRef(deferredEntities[0]) }) + .where({ entity_ref: stringifyEntityRef(deferredEntities[0].entity) }) .select(); expect(refreshStateEntries).toHaveLength(1); @@ -297,12 +466,15 @@ describe('Default Processing Database', () => { sourceKey: 'config', items: [ { - apiVersion: '1.0.0', - metadata: { - name: 'new-root', - }, - kind: 'Location', - } as Entity, + entity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + locationKey: 'file:///tmp/foobar', + }, ], }), ); @@ -411,12 +583,15 @@ describe('Default Processing Database', () => { sourceKey: 'config', items: [ { - apiVersion: '1.0.0', - metadata: { - name: 'new-root', - }, - kind: 'Location', - } as Entity, + entity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + locationKey: 'file:/tmp/foobar', + }, ], }); }); @@ -510,12 +685,15 @@ describe('Default Processing Database', () => { removed: [], added: [ { - apiVersion: '1.0.0', - metadata: { - name: 'new-root', - }, - kind: 'Location', - } as Entity, + entity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + locationKey: 'file:///tmp/foobar', + }, ], }); }); @@ -614,12 +792,15 @@ describe('Default Processing Database', () => { added: [], removed: [ { - apiVersion: '1.0.0', - metadata: { - name: 'new-root', - }, - kind: 'Location', - } as Entity, + entity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + locationKey: 'file:/tmp/foobar', + }, ], }); }); @@ -648,6 +829,73 @@ describe('Default Processing Database', () => { }, 60_000, ); + + it.each(databases.eachSupportedId())( + 'should update the location key during full replace, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await createLocations(knex, ['location:default/removed']); + await insertRefreshStateRow(knex, { + entity_id: uuid.v4(), + entity_ref: 'location:default/replaced', + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '[]', + next_update_at: '2021-04-01 13:37:00', + last_discovery_at: '2021-04-01 13:37:00', + location_key: 'file:///tmp/old', + }); + + await insertRefRow(knex, { + source_key: 'lols', + target_entity_ref: 'location:default/removed', + }); + await insertRefRow(knex, { + source_key: 'lols', + target_entity_ref: 'location:default/replaced', + }); + + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'lols', + items: [ + { + entity: { + apiVersion: '1.0.0', + metadata: { + name: 'replaced', + }, + kind: 'Location', + } as Entity, + locationKey: 'file:///tmp/foobar', + }, + ], + }); + }); + + const currentRefreshState = await knex( + 'refresh_state', + ).select(); + expect(currentRefreshState).toEqual([ + expect.objectContaining({ + entity_ref: 'location:default/replaced', + location_key: 'file:///tmp/foobar', + }), + ]); + + const currentRefRowState = await knex( + 'refresh_state_references', + ).select(); + expect(currentRefRowState).toEqual([ + expect.objectContaining({ + source_key: 'lols', + target_entity_ref: 'location:default/replaced', + }), + ]); + }, + 60_000, + ); }); describe('getProcessableEntities', () => { diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index 79daee13e6..1f1ea4bf84 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -16,12 +16,13 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; -import { ConflictError, NotFoundError } from '@backstage/errors'; +import { ConflictError } from '@backstage/errors'; import { Knex } from 'knex'; import lodash from 'lodash'; import { v4 as uuid } from 'uuid'; import type { Logger } from 'winston'; import { Transaction } from '../../database'; +import { DeferredEntity } from '../processing/types'; import { DbRefreshStateReferencesRow, DbRefreshStateRow, @@ -63,23 +64,34 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { errors, relations, deferredEntities, + locationKey, } = options; - const refreshResult = await tx('refresh_state') .update({ processed_entity: JSON.stringify(processedEntity), cache: JSON.stringify(state), errors, + location_key: locationKey, }) - .where('entity_id', id); + .where('entity_id', id) + .andWhere(inner => { + if (!locationKey) { + return inner.whereNull('location_key'); + } + return inner + .where('location_key', locationKey) + .orWhereNull('location_key'); + }); if (refreshResult === 0) { - throw new NotFoundError(`Processing state not found for ${id}`); + throw new ConflictError( + `Conflicting write of processing result for ${id} with location key '${locationKey}'`, + ); } // Schedule all deferred entities for future processing. await this.addUnprocessedEntities(tx, { entities: deferredEntities, - entityRef: stringifyEntityRef(processedEntity), + sourceEntityRef: stringifyEntityRef(processedEntity), }); // Update fragments @@ -129,32 +141,51 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { private async createDelta( tx: Knex.Transaction, options: ReplaceUnprocessedEntitiesOptions, - ): Promise<{ toAdd: Entity[]; toRemove: string[] }> { + ): Promise<{ toAdd: DeferredEntity[]; toRemove: string[] }> { if (options.type === 'delta') { return { toAdd: options.added, - toRemove: options.removed.map(e => stringifyEntityRef(e)), + toRemove: options.removed.map(e => stringifyEntityRef(e.entity)), }; } + // Grab all of the existing references from the same source, and their locationKeys as well const oldRefs = await tx( 'refresh_state_references', ) .where({ source_key: options.sourceKey }) - .select('target_entity_ref') - .then(rows => rows.map(r => r.target_entity_ref)); + .leftJoin('refresh_state', { + target_entity_ref: 'entity_ref', + }) + .select(['target_entity_ref', 'location_key']); - const items = options.items.map(entity => ({ - entity, - ref: stringifyEntityRef(entity), + const items = options.items.map(deferred => ({ + deferred, + ref: stringifyEntityRef(deferred.entity), })); - const oldRefsSet = new Set(oldRefs); + const oldRefsSet = new Map( + oldRefs.map(r => [r.target_entity_ref, r.location_key]), + ); const newRefsSet = new Set(items.map(item => item.ref)); - const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); - const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); - return { toAdd: toAdd.map(({ entity }) => entity), toRemove }; + const toAdd = new Array(); + const toRemove = oldRefs + .map(row => row.target_entity_ref) + .filter(ref => !newRefsSet.has(ref)); + + for (const item of items) { + if (!oldRefsSet.has(item.ref)) { + // Add any entity that does not exist in the database + toAdd.push(item.deferred); + } else if (oldRefsSet.get(item.ref) !== item.deferred.locationKey) { + // Remove and then re-add any entity that exists, but with a different location key + toRemove.push(item.ref); + toAdd.push(item.deferred); + } + } + + return { toAdd, toRemove }; } async replaceUnprocessedEntities( @@ -281,28 +312,10 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { } if (toAdd.length) { - const state: Knex.DbRecord[] = toAdd.map(entity => ({ - entity_id: uuid(), - entity_ref: stringifyEntityRef(entity), - unprocessed_entity: JSON.stringify(entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - })); - - const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( - entity => ({ - source_key: options.sourceKey, - target_entity_ref: stringifyEntityRef(entity), - }), - ); - // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense - await tx.batchInsert('refresh_state', state, BATCH_SIZE); - await tx.batchInsert( - 'refresh_state_references', - stateReferences, - BATCH_SIZE, - ); + await this.addUnprocessedEntities(tx, { + sourceKey: options.sourceKey, + entities: toAdd, + }); } } @@ -312,44 +325,123 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ): Promise { const tx = txOpaque as Knex.Transaction; - const stateRows = options.entities.map( - entity => - ({ - entity_id: uuid(), - entity_ref: stringifyEntityRef(entity), - unprocessed_entity: JSON.stringify(entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - } as Knex.DbRecord), - ); - const stateReferenceRows = stateRows.map( - stateRow => - ({ - source_entity_ref: options.entityRef, - target_entity_ref: stateRow.entity_ref, - } as Knex.DbRecord), - ); + // Keeps track of the entities that we end up inserting to update refresh_state_references afterwards + const stateReferences = new Array(); + const conflictingStateReferences = new Array(); // Upsert all of the unprocessed entities into the refresh_state table, by // their entity ref. - // TODO(freben): Can this be batched somehow? - for (const row of stateRows) { - await tx('refresh_state') - .insert(row) - .onConflict('entity_ref') - .merge(['unprocessed_entity', 'last_discovery_at']); + for (const { entity, locationKey } of options.entities) { + const entityRef = stringifyEntityRef(entity); + const serializedEntity = JSON.stringify(entity); + + // We optimistically try to update any existing refresh state first, as this is by far + // the most common case. + const refreshResult = await tx('refresh_state') + .update({ + unprocessed_entity: serializedEntity, + location_key: locationKey, + last_discovery_at: tx.fn.now(), + }) + .where('entity_ref', entityRef) + .andWhere(inner => { + if (!locationKey) { + return inner.whereNull('location_key'); + } + return inner + .where('location_key', locationKey) + .orWhereNull('location_key'); + }); + + if (refreshResult === 0) { + // In the event that we can't update an existing refresh state, we first try to insert a new row + try { + let query = tx('refresh_state').insert({ + entity_id: uuid(), + entity_ref: entityRef, + unprocessed_entity: serializedEntity, + errors: '', + location_key: locationKey, + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }); + + // TODO(Rugvip): only tested towards Postgres and SQLite + // We have to do this because the only way to detect if there was a conflict with + // SQLite is to catch the error, while Postgres needs to ignore the conflict to not + // break the ongoing transaction. + if (tx.client.config.client !== 'sqlite3') { + query = query.onConflict('entity_ref').ignore(); + } + + const result: { /* postgres */ rowCount?: number } = await query; + if (result.rowCount === 0) { + throw new ConflictError( + 'Insert failed due to conflicting entity_ref', + ); + } + } catch (error) { + if ( + !error.message.includes('UNIQUE constraint failed') && + error.name !== 'ConflictError' + ) { + throw error; + } + // If the row can't be inserted, we have a conflict, but it could be either + // because of a conflicting locationKey or a race with another instance, so check + // whether the conflicting entity has the same entityRef but a different locationKey + const [conflictingEntity] = await tx( + 'refresh_state', + ) + .where({ entity_ref: entityRef }) + .select(); + + // If the location key matches it means we just had a race trigger, which we can safely ignore + if ( + !conflictingEntity || + conflictingEntity.location_key !== locationKey + ) { + this.options.logger.warn( + `Detected conflicting entityRef ${entityRef} already referenced by ${conflictingEntity.location_key} and now also ${locationKey}`, + ); + conflictingStateReferences.push(entityRef); + continue; + } + } + } + + // Skipped on locationKey conflict + stateReferences.push(entityRef); } - // Replace all references for the originating entity before creating new ones - await tx('refresh_state_references') - .where({ source_entity_ref: options.entityRef }) - .delete(); - await tx.batchInsert( - 'refresh_state_references', - stateReferenceRows, - BATCH_SIZE, - ); + // Replace all references for the originating entity or source and then create new ones + if ('sourceKey' in options) { + await tx('refresh_state_references') + .whereNotIn('target_entity_ref', conflictingStateReferences) + .andWhere({ source_key: options.sourceKey }) + .delete(); + await tx.batchInsert( + 'refresh_state_references', + stateReferences.map(entityRef => ({ + source_key: options.sourceKey, + target_entity_ref: entityRef, + })), + BATCH_SIZE, + ); + } else { + await tx('refresh_state_references') + .whereNotIn('target_entity_ref', conflictingStateReferences) + .andWhere({ source_entity_ref: options.sourceEntityRef }) + .delete(); + await tx.batchInsert( + 'refresh_state_references', + stateReferences.map(entityRef => ({ + source_entity_ref: options.sourceEntityRef, + target_entity_ref: entityRef, + })), + BATCH_SIZE, + ); + } } async getProcessableEntities( @@ -406,6 +498,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ? JSON.parse(i.cache) : new Map(), errors: i.errors, + locationKey: i.location_key, } as RefreshStateItem), ), }; diff --git a/plugins/catalog-backend/src/next/database/tables.ts b/plugins/catalog-backend/src/next/database/tables.ts index 93453ba914..395694131d 100644 --- a/plugins/catalog-backend/src/next/database/tables.ts +++ b/plugins/catalog-backend/src/next/database/tables.ts @@ -29,6 +29,7 @@ export type DbRefreshStateRow = { next_update_at: string; last_discovery_at: string; // remove? errors?: string; + location_key?: string; }; export type DbRefreshStateReferencesRow = { diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index dd342126c4..78199900e4 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -17,11 +17,17 @@ import { Entity, EntityRelationSpec } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; import { Transaction } from '../../database/types'; +import { DeferredEntity } from '../processing/types'; -export type AddUnprocessedEntitiesOptions = { - entityRef: string; - entities: Entity[]; -}; +export type AddUnprocessedEntitiesOptions = + | { + sourceEntityRef: string; + entities: DeferredEntity[]; + } + | { + sourceKey: string; + entities: DeferredEntity[]; + }; export type AddUnprocessedEntitiesResult = {}; @@ -31,7 +37,8 @@ export type UpdateProcessedEntityOptions = { state?: Map; errors?: string; relations: EntityRelationSpec[]; - deferredEntities: Entity[]; + deferredEntities: DeferredEntity[]; + locationKey?: string; }; export type UpdateProcessedEntityErrorsOptions = { @@ -48,6 +55,7 @@ export type RefreshStateItem = { lastDiscoveryAt: string; // remove? state: Map; errors?: string; + locationKey?: string; }; export type GetProcessableEntitiesResult = { @@ -57,24 +65,19 @@ export type GetProcessableEntitiesResult = { export type ReplaceUnprocessedEntitiesOptions = | { sourceKey: string; - items: Entity[]; + items: DeferredEntity[]; type: 'full'; } | { sourceKey: string; - added: Entity[]; - removed: Entity[]; + added: DeferredEntity[]; + removed: DeferredEntity[]; type: 'delta'; }; export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; - addUnprocessedEntities( - tx: Transaction, - options: AddUnprocessedEntitiesOptions, - ): Promise; - replaceUnprocessedEntities( txOpaque: Transaction, options: ReplaceUnprocessedEntitiesOptions, diff --git a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts index 48a40a2f6e..bb23b6afb3 100644 --- a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts @@ -68,7 +68,6 @@ export class DefaultCatalogProcessingOrchestrator async process( request: EntityProcessingRequest, ): Promise { - // TODO: implement dryRun/eager return this.processSingleEntity(request.entity); } diff --git a/plugins/catalog-backend/src/next/processing/ProcessorOutputCollector.ts b/plugins/catalog-backend/src/next/processing/ProcessorOutputCollector.ts index 3f8594bd48..507def964a 100644 --- a/plugins/catalog-backend/src/next/processing/ProcessorOutputCollector.ts +++ b/plugins/catalog-backend/src/next/processing/ProcessorOutputCollector.ts @@ -24,7 +24,12 @@ import { import { Logger } from 'winston'; import { CatalogProcessorResult } from '../../ingestion'; import { locationSpecToLocationEntity } from '../util'; -import { getEntityOriginLocationRef, validateEntityEnvelope } from './util'; +import { DeferredEntity } from './types'; +import { + getEntityLocationRef, + getEntityOriginLocationRef, + validateEntityEnvelope, +} from './util'; /** * Helper class for aggregating all of the emitted data from processors. @@ -32,7 +37,7 @@ import { getEntityOriginLocationRef, validateEntityEnvelope } from './util'; export class ProcessorOutputCollector { private readonly errors = new Array(); private readonly relations = new Array(); - private readonly deferredEntities = new Array(); + private readonly deferredEntities = new Array(); private done = false; constructor( @@ -73,6 +78,8 @@ export class ProcessorOutputCollector { return; } + const location = stringifyLocationReference(i.location); + // Note that at this point, we have only validated the envelope part of // the entity data. Annotations are not part of that, so we have to be // defensive. If the annotations were malformed (e.g. were not a valid @@ -81,7 +88,6 @@ export class ProcessorOutputCollector { const annotations = entity.metadata.annotations || {}; if (typeof annotations === 'object' && !Array.isArray(annotations)) { const originLocation = getEntityOriginLocationRef(this.parentEntity); - const location = stringifyLocationReference(i.location); entity = { ...entity, metadata: { @@ -95,11 +101,14 @@ export class ProcessorOutputCollector { }; } - this.deferredEntities.push(entity); + this.deferredEntities.push({ entity, locationKey: location }); } else if (i.type === 'location') { - this.deferredEntities.push( - locationSpecToLocationEntity(i.location, this.parentEntity), + const entity = locationSpecToLocationEntity( + i.location, + this.parentEntity, ); + const locationKey = getEntityLocationRef(entity); + this.deferredEntities.push({ entity, locationKey }); } else if (i.type === 'relation') { this.relations.push(i.relation); } else if (i.type === 'error') { diff --git a/plugins/catalog-backend/src/next/processing/types.ts b/plugins/catalog-backend/src/next/processing/types.ts index e76e789e54..9e30d07404 100644 --- a/plugins/catalog-backend/src/next/processing/types.ts +++ b/plugins/catalog-backend/src/next/processing/types.ts @@ -27,7 +27,7 @@ export type EntityProcessingResult = ok: true; state: Map; completedEntity: Entity; - deferredEntities: Entity[]; + deferredEntities: DeferredEntity[]; relations: EntityRelationSpec[]; errors: Error[]; } @@ -39,3 +39,8 @@ export type EntityProcessingResult = export interface CatalogProcessingOrchestrator { process(request: EntityProcessingRequest): Promise; } + +export type DeferredEntity = { + entity: Entity; + locationKey?: string; +}; diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index c689f288aa..3b27dd7d10 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -15,6 +15,7 @@ */ import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import { DeferredEntity } from './processing/types'; export interface LocationService { createLocation( @@ -39,8 +40,8 @@ export interface CatalogProcessingEngine { } export type EntityProviderMutation = - | { type: 'full'; entities: Entity[] } - | { type: 'delta'; added: Entity[]; removed: Entity[] }; + | { type: 'full'; entities: DeferredEntity[] } + | { type: 'delta'; added: DeferredEntity[]; removed: DeferredEntity[] }; export interface EntityProviderConnection { applyMutation(mutation: EntityProviderMutation): Promise; diff --git a/plugins/catalog-graphql/api-report.md b/plugins/catalog-graphql/api-report.md index cbe325f4c5..40e8a19b66 100644 --- a/plugins/catalog-graphql/api-report.md +++ b/plugins/catalog-graphql/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import { GraphQLModule } from '@graphql-modules/core'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createModule(options: ModuleOptions): Promise; @@ -16,7 +16,7 @@ export interface ModuleOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; } diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 4e30d2d218..d14ae5df42 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -52,7 +52,7 @@ "eslint-plugin-graphql": "^4.0.0", "msw": "^0.29.0", "supertest": "^6.1.3", - "ts-node": "^9.1.1" + "ts-node": "^10.0.0" }, "files": [ "dist" diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index abe8e988e9..2b18c2e57a 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-client": "^0.3.14", "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.4", @@ -54,8 +54,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 8ab8f75db1..e2c3f2dc47 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -46,29 +46,19 @@ jest.doMock('@octokit/rest', () => { return { Octokit }; }); -// Mock the value to control which integrations are activated -jest.mock('./GitHub', () => ({ - getGithubIntegrationConfig: jest.fn(), -})); - -import { - GitHubIntegrationConfig, - ScmIntegrations, -} from '@backstage/integration'; +import { ScmIntegrations } from '@backstage/integration'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { msw } from '@backstage/test-utils'; import { Octokit } from '@octokit/rest'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogImportClient } from './CatalogImportClient'; -import { getGithubIntegrationConfig } from './GitHub'; import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api'; import { OAuthApi } from '@backstage/core-plugin-api'; -const server = setupServer(); - describe('CatalogImportClient', () => { + const server = setupServer(); msw.setupDefaultHandlers(server); const mockBaseUrl = 'http://backstage:9191/api/catalog'; @@ -92,7 +82,19 @@ describe('CatalogImportClient', () => { }, }; - const scmIntegrationsApi = ScmIntegrations.fromConfig(new ConfigReader({})); + const scmIntegrationsApi = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'example.com' }], + gitlab: [ + { + host: 'registered-but-not-github.com', + apiBaseUrl: 'https://registered-but-not-github.com', + }, + ], + }, + }), + ); const catalogApi: jest.Mocked = { getEntities: jest.fn(), @@ -106,7 +108,6 @@ describe('CatalogImportClient', () => { }; let catalogImportClient: CatalogImportClient; - let getGithubIntegrationConfigFn: jest.Mock; beforeEach(() => { catalogImportClient = new CatalogImportClient({ @@ -116,9 +117,6 @@ describe('CatalogImportClient', () => { identityApi, catalogApi, }); - - getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock; - getGithubIntegrationConfigFn.mockReset(); }); describe('analyzeUrl', () => { @@ -169,10 +167,22 @@ describe('CatalogImportClient', () => { }); }); - it('should ignore missing github integration', async () => { + it('should reject for integrations that are not github ones', async () => { await expect( catalogImportClient.analyzeUrl( - 'https://github.com/backstage/backstage', + 'https://registered-but-not-github.com/backstage/backstage', + ), + ).rejects.toThrow( + new Error( + 'The registered-but-not-github.com integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?', + ), + ); + }); + + it('should reject when unable to match with any integration', async () => { + await expect( + catalogImportClient.analyzeUrl( + 'https://not-registered-as-integration.com/foo/bar', ), ).rejects.toThrow( new Error( @@ -182,12 +192,6 @@ describe('CatalogImportClient', () => { }); it('should find locations from github', async () => { - getGithubIntegrationConfigFn.mockReturnValue({ - repo: 'backstage', - owner: 'backstage', - githubIntegrationConfig: {} as GitHubIntegrationConfig, - }); - ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ data: { total_count: 2, @@ -244,12 +248,6 @@ describe('CatalogImportClient', () => { }); it('should find repository from github', async () => { - getGithubIntegrationConfigFn.mockReturnValue({ - repo: 'backstage', - owner: 'backstage', - githubIntegrationConfig: {} as GitHubIntegrationConfig, - }); - ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ data: { total_count: 0, items: [] }, }); @@ -300,14 +298,6 @@ describe('CatalogImportClient', () => { describe('submitPullRequest', () => { it('should create GitHub pull request', async () => { - getGithubIntegrationConfigFn.mockReturnValue({ - repo: 'backstage', - owner: 'backstage', - githubIntegrationConfig: { - host: 'github.com', - } as GitHubIntegrationConfig, - }); - await expect( catalogImportClient.submitPullRequest({ repositoryUrl: 'https://github.com/backstage/backstage', diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index f53570617e..aa29c95247 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -77,6 +77,12 @@ export class CatalogImportClient implements CatalogImportApi { const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url); if (!ghConfig) { + const other = this.scmIntegrationsApi.byUrl(url); + if (other) { + throw new Error( + `The ${other.title} integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?`, + ); + } throw new Error( 'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.', ); diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index 57cfc0d800..c7363dda54 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-react +## 0.2.5 + +### Patch Changes + +- 35a3e4e4c: Fix repetitive error reporting in EntityTypePicker +- Updated dependencies + - @backstage/core-app-api@0.1.4 + - @backstage/core-components@0.1.4 + - @backstage/integration@0.5.7 + - @backstage/catalog-client@0.3.15 + ## 0.2.4 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index e028516689..8c760a16d0 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-react", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,12 +28,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.14", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", - "@backstage/core-app-api": "^0.1.3", - "@backstage/core-components": "^0.1.3", + "@backstage/core-app-api": "^0.1.4", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx index 52b8ec8ff1..7b7845b8bc 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { capitalize } from 'lodash'; import { Box } from '@material-ui/core'; import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter'; @@ -26,13 +26,16 @@ export const EntityTypePicker = () => { const alertApi = useApi(alertApiRef); const { error, types, selectedType, setType } = useEntityTypeFilter(); - if (!types) return null; + useEffect(() => { + if (error) { + alertApi.post({ + message: `Failed to load entity types`, + severity: 'error', + }); + } + }, [error, alertApi]); - if (error) { - alertApi.post({ - message: `Failed to load entity types`, - severity: 'error', - }); + if (!types || error) { return null; } diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index c14c458aaa..407f0f0f3f 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog +## 0.6.5 + +### Patch Changes + +- f423891ee: Fixed sizing of the System diagram when the rendered graph was wider than the container. +- e19283b39: Get rid of flex console warning for IconLink +- Updated dependencies + - @backstage/plugin-catalog-react@0.2.5 + - @backstage/core-components@0.1.4 + - @backstage/integration@0.5.7 + - @backstage/catalog-client@0.3.15 + ## 0.6.4 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 3c3095e369..776bac97c8 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.6.4", + "version": "0.6.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,14 +30,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.14", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@backstage/integration-react": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.2.4", + "@backstage/plugin-catalog-react": "^0.2.5", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -54,8 +54,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ba19d6036c..089e61f0fb 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { makeStyles } from '@material-ui/core'; +import { Grid } from '@material-ui/core'; import { EntityKindPicker, EntityLifecyclePicker, @@ -39,18 +39,6 @@ import { TableProps, } from '@backstage/core-components'; -const useStyles = makeStyles(theme => ({ - contentWrapper: { - display: 'grid', - gridTemplateAreas: "'filters' 'table'", - gridTemplateColumns: '250px 1fr', - gridColumnGap: theme.spacing(2), - }, - buttonSpacing: { - marginLeft: theme.spacing(2), - }, -})); - export type CatalogPageProps = { initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; @@ -61,30 +49,36 @@ export const CatalogPage = ({ initiallySelectedFilter = 'owned', columns, actions, -}: CatalogPageProps) => { - const styles = useStyles(); - - return ( - - - - - All your software catalog entities - -
- -
-
+}: CatalogPageProps) => ( + + + + + All your software catalog entities + + + + + + + + + + + + + + + + + + - -
-
-
- ); -}; + + + + + +); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx index 5af4755bcb..272ad8a6d6 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx @@ -29,7 +29,7 @@ import { getEntityRelations, useEntity, } from '@backstage/plugin-catalog-react'; -import { Box, makeStyles, Typography } from '@material-ui/core'; +import { Box, makeStyles, Typography, useTheme } from '@material-ui/core'; import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; import React from 'react'; import { useAsync } from 'react-use'; @@ -151,6 +151,7 @@ function RenderNode(props: DependencyGraphTypes.RenderNodeProps) { */ export function SystemDiagramCard() { const { entity } = useEntity(); + const theme = useTheme(); const currentSystemName = entity.metadata.name; const currentSystemNode = stringifyEntityRef(entity); const systemNodes = new Array<{ id: string; kind: string; name: string }>(); @@ -262,6 +263,8 @@ export function SystemDiagramCard() { nodeMargin={10} direction={DependencyGraphTypes.Direction.BOTTOM_TOP} renderNode={RenderNode} + paddingX={theme.spacing(4)} + paddingY={theme.spacing(4)} /> ', () => { namespace: 'my-namespace', }, spec: { + profile: { + displayName: 'Group A', + }, type: 'organization', }, }, @@ -64,6 +67,6 @@ describe('', () => { }, ); - expect(getByText('my-namespace/group-a')).toBeInTheDocument(); + expect(getByText('Group A')).toBeInTheDocument(); }); }); diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx index 53e55b96bf..ce09f0d268 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx @@ -18,6 +18,7 @@ import { RELATION_CHILD_OF, stringifyEntityRef, parseEntityRef, + GroupEntity, } from '@backstage/catalog-model'; import { catalogApiRef, @@ -107,7 +108,11 @@ function RenderNode(props: DependencyGraphTypes.RenderNodeProps) { * Dynamically generates a diagram of groups registered in the catalog. */ export function GroupsDiagram() { - const nodes = new Array<{ id: string; kind: string; name: string }>(); + const nodes = new Array<{ + id: string; + kind: string; + name: string; + }>(); const edges = new Array<{ from: string; to: string; label: string }>(); const configApi = useApi(configApiRef); @@ -141,7 +146,9 @@ export function GroupsDiagram() { nodes.push({ id: stringifyEntityRef(catalogItem), kind: catalogItem.kind, - name: formatEntityRefTitle(catalogItem, { defaultKind: 'Group' }), + name: + (catalogItem as GroupEntity).spec?.profile?.displayName || + formatEntityRefTitle(catalogItem, { defaultKind: 'Group' }), }); // Edge to parent diff --git a/plugins/explore/src/components/ToolCard/ToolCard.tsx b/plugins/explore/src/components/ToolCard/ToolCard.tsx index 2baa0cffda..904db0c00b 100644 --- a/plugins/explore/src/components/ToolCard/ToolCard.tsx +++ b/plugins/explore/src/components/ToolCard/ToolCard.tsx @@ -18,7 +18,6 @@ import { ExploreTool } from '@backstage/plugin-explore-react'; import { BackstageTheme } from '@backstage/theme'; import { Box, - Button, Card, CardActions, CardContent, @@ -27,6 +26,7 @@ import { makeStyles, Typography, } from '@material-ui/core'; +import { Button } from '@backstage/core-components'; import classNames from 'classnames'; import React from 'react'; @@ -97,7 +97,7 @@ export const ToolCard = ({ card, objectFit }: Props) => { )} - diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 7c74f26281..8c11799ef3 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.2.4", @@ -48,8 +48,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 623ff72c49..2d8de17196 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -42,8 +42,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index 446ecaf8fe..c736f082fb 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/integration": "^0.5.6", "@backstage/theme": "^0.2.8", @@ -37,8 +37,8 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/git-release-manager/src/features/Features.test.tsx b/plugins/git-release-manager/src/features/Features.test.tsx index ac3004f690..b5404a21d9 100644 --- a/plugins/git-release-manager/src/features/Features.test.tsx +++ b/plugins/git-release-manager/src/features/Features.test.tsx @@ -69,6 +69,7 @@ describe('Features', () => { diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index 95bf5e10cb..20717aed60 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-github-actions +## 0.4.11 + +### Patch Changes + +- 316530684: Migrate from Moment to Luxon +- Updated dependencies + - @backstage/plugin-catalog-react@0.2.5 + - @backstage/core-components@0.1.4 + - @backstage/integration@0.5.7 + ## 0.4.10 ### Patch Changes diff --git a/plugins/github-actions/dev/index.tsx b/plugins/github-actions/dev/index.tsx index dfda83909b..ee761b3504 100644 --- a/plugins/github-actions/dev/index.tsx +++ b/plugins/github-actions/dev/index.tsx @@ -14,7 +14,65 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { githubActionsPlugin } from '../src/plugin'; +import { EntityGithubActionsContent, githubActionsPlugin } from '../src/plugin'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { GithubActionsApi, githubActionsApiRef } from '../src'; +import getWorkflowRunResponse from '../src/__fixtures__/get-workflow-run.json'; +import listJobsForWorkflowRunResponse from '../src/__fixtures__/list-jobs-for-workflow-run.json'; -createDevApp().registerPlugin(githubActionsPlugin).render(); +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'backstage', + description: 'backstage.io', + annotations: { + 'github.com/project-slug': 'backstage/backstage', + }, + }, + spec: { + lifecycle: 'production', + type: 'website', + owner: 'user:guest', + }, +}; + +const mockGithubActionsApi: GithubActionsApi = { + async downloadJobLogsForWorkflowRun() {}, + async getWorkflow() { + return {} as any; + }, + async getWorkflowRun() { + return getWorkflowRunResponse; + }, + async listJobsForWorkflowRun() { + return listJobsForWorkflowRunResponse as any; + }, + async listWorkflowRuns() { + return {} as any; + }, + async reRunWorkflow() { + return {} as any; + }, +}; + +createDevApp() + .registerApi({ + api: githubActionsApiRef, + deps: {}, + factory: () => mockGithubActionsApi, + }) + .registerPlugin(githubActionsPlugin) + .addPage({ + path: '/github-actions', + title: 'Github Actions', + element: ( + + + + ), + }) + .render(); diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 1948b8babe..9d775fa09b 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.4.10", + "version": "0.4.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,16 +33,16 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", - "@backstage/integration": "^0.5.6", - "@backstage/plugin-catalog-react": "^0.2.4", + "@backstage/integration": "^0.5.7", + "@backstage/plugin-catalog-react": "^0.2.5", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@octokit/rest": "^18.5.3", - "moment": "^2.27.0", + "luxon": "^1.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.3", @@ -51,8 +51,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/src/__fixtures__/get-workflow-run.json b/plugins/github-actions/src/__fixtures__/get-workflow-run.json new file mode 100644 index 0000000000..5527b356f1 --- /dev/null +++ b/plugins/github-actions/src/__fixtures__/get-workflow-run.json @@ -0,0 +1,176 @@ +{ + "id": 30433642, + "name": "Build", + "node_id": "MDEyOldvcmtmbG93IFJ1bjI2OTI4OQ==", + "check_suite_id": 42, + "check_suite_node_id": "MDEwOkNoZWNrU3VpdGU0Mg==", + "head_branch": "master", + "head_sha": "acb5820ced9479c074f688cc328bf03f341a511d", + "run_number": 562, + "event": "push", + "status": "queued", + "conclusion": null, + "workflow_id": 159038, + "url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642", + "html_url": "https://github.com/octo-org/octo-repo/actions/runs/30433642", + "pull_requests": [], + "created_at": "2020-01-22T19:33:08Z", + "updated_at": "2020-01-22T19:33:08Z", + "jobs_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/jobs", + "logs_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/logs", + "check_suite_url": "https://api.github.com/repos/octo-org/octo-repo/check-suites/414944374", + "artifacts_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/artifacts", + "cancel_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/cancel", + "rerun_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/rerun", + "workflow_url": "https://api.github.com/repos/octo-org/octo-repo/actions/workflows/159038", + "head_commit": { + "id": "acb5820ced9479c074f688cc328bf03f341a511d", + "tree_id": "d23f6eedb1e1b9610bbc754ddb5197bfe7271223", + "message": "Create linter.yaml", + "timestamp": "2020-01-22T19:33:05Z", + "author": { + "name": "Octo Cat", + "email": "octocat@github.com" + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com" + } + }, + "repository": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks" + }, + "head_repository": { + "id": 217723378, + "node_id": "MDEwOlJlcG9zaXRvcnkyMTc3MjMzNzg=", + "name": "octo-repo", + "full_name": "octo-org/octo-repo", + "private": true, + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/octo-org/octo-repo", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/octo-org/octo-repo", + "forks_url": "https://api.github.com/repos/octo-org/octo-repo/forks", + "keys_url": "https://api.github.com/repos/octo-org/octo-repo/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/octo-org/octo-repo/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/octo-org/octo-repo/teams", + "hooks_url": "https://api.github.com/repos/octo-org/octo-repo/hooks", + "issue_events_url": "https://api.github.com/repos/octo-org/octo-repo/issues/events{/number}", + "events_url": "https://api.github.com/repos/octo-org/octo-repo/events", + "assignees_url": "https://api.github.com/repos/octo-org/octo-repo/assignees{/user}", + "branches_url": "https://api.github.com/repos/octo-org/octo-repo/branches{/branch}", + "tags_url": "https://api.github.com/repos/octo-org/octo-repo/tags", + "blobs_url": "https://api.github.com/repos/octo-org/octo-repo/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/octo-org/octo-repo/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/octo-org/octo-repo/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/octo-org/octo-repo/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/octo-org/octo-repo/statuses/{sha}", + "languages_url": "https://api.github.com/repos/octo-org/octo-repo/languages", + "stargazers_url": "https://api.github.com/repos/octo-org/octo-repo/stargazers", + "contributors_url": "https://api.github.com/repos/octo-org/octo-repo/contributors", + "subscribers_url": "https://api.github.com/repos/octo-org/octo-repo/subscribers", + "subscription_url": "https://api.github.com/repos/octo-org/octo-repo/subscription", + "commits_url": "https://api.github.com/repos/octo-org/octo-repo/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/octo-org/octo-repo/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/octo-org/octo-repo/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/octo-org/octo-repo/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/octo-org/octo-repo/contents/{+path}", + "compare_url": "https://api.github.com/repos/octo-org/octo-repo/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/octo-org/octo-repo/merges", + "archive_url": "https://api.github.com/repos/octo-org/octo-repo/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/octo-org/octo-repo/downloads", + "issues_url": "https://api.github.com/repos/octo-org/octo-repo/issues{/number}", + "pulls_url": "https://api.github.com/repos/octo-org/octo-repo/pulls{/number}", + "milestones_url": "https://api.github.com/repos/octo-org/octo-repo/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octo-org/octo-repo/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/octo-org/octo-repo/labels{/name}", + "releases_url": "https://api.github.com/repos/octo-org/octo-repo/releases{/id}", + "deployments_url": "https://api.github.com/repos/octo-org/octo-repo/deployments" + } +} diff --git a/plugins/github-actions/src/__fixtures__/list-jobs-for-workflow-run.json b/plugins/github-actions/src/__fixtures__/list-jobs-for-workflow-run.json new file mode 100644 index 0000000000..1c3837baa1 --- /dev/null +++ b/plugins/github-actions/src/__fixtures__/list-jobs-for-workflow-run.json @@ -0,0 +1,102 @@ +{ + "total_count": 1, + "jobs": [ + { + "id": 399444496, + "run_id": 29679449, + "run_url": "https://api.github.com/repos/octo-org/octo-repo/actions/runs/29679449", + "node_id": "MDEyOldvcmtmbG93IEpvYjM5OTQ0NDQ5Ng==", + "head_sha": "f83a356604ae3c5d03e1b46ef4d1ca77d64a90b0", + "url": "https://api.github.com/repos/octo-org/octo-repo/actions/jobs/399444496", + "html_url": "https://github.com/octo-org/octo-repo/runs/399444496", + "status": "completed", + "conclusion": "success", + "started_at": "2020-01-20T17:42:40Z", + "completed_at": "2020-01-20T17:44:39Z", + "name": "build", + "steps": [ + { + "name": "Set up job", + "status": "completed", + "conclusion": "success", + "number": 1, + "started_at": "2020-01-20T09:42:40.000-08:00", + "completed_at": "2020-01-20T09:42:41.000-08:00" + }, + { + "name": "Run actions/checkout@v2", + "status": "completed", + "conclusion": "success", + "number": 2, + "started_at": "2020-01-20T09:42:41.000-08:00", + "completed_at": "2020-01-20T09:42:45.000-08:00" + }, + { + "name": "Set up Ruby", + "status": "completed", + "conclusion": "success", + "number": 3, + "started_at": "2020-01-20T09:42:45.000-08:00", + "completed_at": "2020-01-20T09:42:45.000-08:00" + }, + { + "name": "Run actions/cache@v2", + "status": "completed", + "conclusion": "success", + "number": 4, + "started_at": "2020-01-20T09:42:45.000-08:00", + "completed_at": "2020-01-20T09:42:48.000-08:00" + }, + { + "name": "Install Bundler", + "status": "completed", + "conclusion": "success", + "number": 5, + "started_at": "2020-01-20T09:42:48.000-08:00", + "completed_at": "2020-01-20T09:42:52.000-08:00" + }, + { + "name": "Install Gems", + "status": "completed", + "conclusion": "success", + "number": 6, + "started_at": "2020-01-20T09:42:52.000-08:00", + "completed_at": "2020-01-20T09:42:53.000-08:00" + }, + { + "name": "Run Tests", + "status": "completed", + "conclusion": "success", + "number": 7, + "started_at": "2020-01-20T09:42:53.000-08:00", + "completed_at": "2020-01-20T09:42:59.000-08:00" + }, + { + "name": "Deploy to Heroku", + "status": "completed", + "conclusion": "success", + "number": 8, + "started_at": "2020-01-20T09:42:59.000-08:00", + "completed_at": "2020-01-20T09:44:39.000-08:00" + }, + { + "name": "Post actions/cache@v2", + "status": "completed", + "conclusion": "success", + "number": 16, + "started_at": "2020-01-20T09:44:39.000-08:00", + "completed_at": "2020-01-20T09:44:39.000-08:00" + }, + { + "name": "Complete job", + "status": "completed", + "conclusion": "success", + "number": 17, + "started_at": "2020-01-20T09:44:39.000-08:00", + "completed_at": "2020-01-20T09:44:39.000-08:00" + } + ], + "check_run_url": "https://api.github.com/repos/octo-org/octo-repo/check-runs/399444496" + } + ] +} diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index 4b5da51ce8..aebf7dfa01 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -36,7 +36,7 @@ import { } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExternalLinkIcon from '@material-ui/icons/Launch'; -import moment from 'moment'; +import { DateTime } from 'luxon'; import React from 'react'; import { Job, Jobs, Step } from '../../api'; import { useProjectName } from '../useProjectName'; @@ -74,8 +74,10 @@ const useStyles = makeStyles(theme => ({ })); const getElapsedTime = (start: string, end: string) => { - const diff = moment(moment(end || moment()).diff(moment(start))); - const timeElapsed = diff.format('m [minutes] s [seconds]'); + const startDate = DateTime.fromISO(start); + const endDate = end ? DateTime.fromISO(end) : DateTime.now(); + const diff = endDate.diff(startDate); + const timeElapsed = diff.toFormat(`m 'minutes' s 'seconds'`); return timeElapsed; }; diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 92b9c78c6a..b65747eaa1 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.6", @@ -38,8 +38,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index fb9d674b5f..ed4695372c 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -43,8 +43,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 0723062115..58b0aac809 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -44,8 +44,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphql/api-report.md b/plugins/graphql/api-report.md index 8109c94122..ef172afbd7 100644 --- a/plugins/graphql/api-report.md +++ b/plugins/graphql/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -16,7 +16,7 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; } diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json index 479fd2223d..521f379d64 100644 --- a/plugins/ilert/package.json +++ b/plugins/ilert/package.json @@ -21,12 +21,12 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", - "@date-io/luxon": "1.x", + "@date-io/luxon": "2.x", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -38,8 +38,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index ae1535b371..20674f92ef 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -48,8 +48,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index 2ed4fdac35..5b1d8520a3 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-kafka-backend +## 0.2.7 + +### Patch Changes + +- 8ab897e01: Properly return a 404 when an unknown cluster is given +- Updated dependencies + - @backstage/backend-common@0.8.4 + ## 0.2.6 ### Patch Changes diff --git a/plugins/kafka-backend/api-report.md b/plugins/kafka-backend/api-report.md index 47754ad572..6840bd7527 100644 --- a/plugins/kafka-backend/api-report.md +++ b/plugins/kafka-backend/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 617dd2d3cb..f06837a8e8 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka-backend", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.2", + "@backstage/backend-common": "^0.8.4", "@backstage/catalog-model": "^0.8.2", "@backstage/config": "^0.1.5", + "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -42,7 +43,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.0", + "@backstage/cli": "^0.7.3", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka-backend/src/service/router.test.ts b/plugins/kafka-backend/src/service/router.test.ts index d3f19f720f..4f458fe543 100644 --- a/plugins/kafka-backend/src/service/router.test.ts +++ b/plugins/kafka-backend/src/service/router.test.ts @@ -17,7 +17,7 @@ import request from 'supertest'; import express from 'express'; import { makeRouter, ClusterApi } from './router'; -import { getVoidLogger } from '@backstage/backend-common'; +import { errorHandler, getVoidLogger } from '@backstage/backend-common'; import { KafkaApi } from './KafkaApi'; import { when } from 'jest-when'; @@ -111,6 +111,19 @@ describe('router', () => { ); }); + it('handles unknown cluster errors correctly', async () => { + const response = await request(app.use(errorHandler())).get( + '/consumers/unknown/hey/offsets', + ); + expect(response.status).toEqual(404); + expect(response.body).toMatchObject({ + error: { + message: + 'Found no configured cluster "unknown", candidates are "dev", "prod"', + }, + }); + }); + it('handles internal error correctly', async () => { prodKafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no')); diff --git a/plugins/kafka-backend/src/service/router.ts b/plugins/kafka-backend/src/service/router.ts index 68f3924f89..6c8bce7af3 100644 --- a/plugins/kafka-backend/src/service/router.ts +++ b/plugins/kafka-backend/src/service/router.ts @@ -18,6 +18,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; import { KafkaApi, KafkaJsApiImpl } from './KafkaApi'; import _ from 'lodash'; import { getClusterDetails } from '../config/ClusterReader'; @@ -45,12 +46,20 @@ export const makeRouter = ( const clusterId = req.params.clusterId; const consumerId = req.params.consumerId; + const kafkaApi = kafkaApiByClusterName[clusterId]; + if (!kafkaApi) { + const candidates = Object.keys(kafkaApiByClusterName) + .map(n => `"${n}"`) + .join(', '); + throw new NotFoundError( + `Found no configured cluster "${clusterId}", candidates are ${candidates}`, + ); + } + logger.info( `Fetch consumer group ${consumerId} offsets from cluster ${clusterId}`, ); - const kafkaApi = kafkaApiByClusterName[clusterId]; - const groupOffsets = await kafkaApi.api.fetchGroupOffsets(consumerId); const groupWithTopicOffsets = await Promise.all( @@ -68,6 +77,7 @@ export const makeRouter = ( })); }), ); + res.json({ consumerId, offsets: groupWithTopicOffsets.flat() }); }); diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 9d8880720e..70e4eea486 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -34,8 +34,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index fe43e8888a..a9389b7e62 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -9,7 +9,7 @@ import express from 'express'; import { FetchResponse } from '@backstage/plugin-kubernetes-common'; import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export interface ClusterDetails { @@ -68,7 +68,7 @@ export interface KubernetesServiceLocator { } // @public (undocumented) -export const makeRouter: (logger: Logger, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router; +export const makeRouter: (logger: Logger_2, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router; // @public (undocumented) export interface ObjectFetchParams { @@ -91,7 +91,7 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; } // @public (undocumented) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 39297dd109..72f576616e 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -44,7 +44,7 @@ "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "helmet": "^4.0.0", "lodash": "^4.17.15", "morgan": "^1.10.0", diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 7d4149cea2..3a172766c6 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/plugin-kubernetes-common": "^0.1.1", @@ -50,8 +50,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 1d5415a521..fa82243a41 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -47,8 +47,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 4bbcd13440..8d46f381e5 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -42,8 +42,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 2ff0fac43b..65710f0615 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -35,8 +35,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 853dbc53e8..b44fbe6c5c 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -23,23 +23,30 @@ import { EntityRefLinks, getEntityRelations, useEntity, + getEntityMetadataEditUrl, } from '@backstage/plugin-catalog-react'; import { Box, Grid, - Link, List, ListItem, ListItemIcon, ListItemText, Tooltip, + IconButton, } from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; +import EditIcon from '@material-ui/icons/Edit'; import Alert from '@material-ui/lab/Alert'; import React from 'react'; -import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { + Avatar, + InfoCard, + InfoCardVariants, + Link, +} from '@backstage/core-components'; const CardTitle = ({ title }: { title: string }) => ( @@ -72,14 +79,31 @@ export const GroupProfileCard = ({ kind: 'group', }); + const entityMetadataEditUrl = getEntityMetadataEditUrl(group); + const displayName = profile?.displayName ?? name; - const emailHref = profile?.email ? `mailto:${profile.email}` : undefined; + const emailHref = profile?.email ? `mailto:${profile.email}` : '#'; + const infoCardAction = entityMetadataEditUrl ? ( + + + + ) : ( + + + + ); return ( } subheader={description} variant={variant} + action={infoCardAction} > @@ -95,7 +119,7 @@ export const GroupProfileCard = ({ - {profile.email} + {profile.email} )} diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index e1ec7f89c7..bdafeb984a 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -8,11 +8,21 @@ There is also an easy way to trigger an alarm directly to the person who is curr This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document. -This plugin provides: +## Features -- A list of incidents -- A way to trigger an alarm to the person on-call -- Information details about the person on-call +### View any open incidents + +![PagerDuty plugin showing no incidents and the on-call rotation](doc/pd1.png) + +### Email link, and view contact information for staff on call + +![PagerDuty plugin showing on-call rotation contact information](doc/pd2.png) + +### Trigger an incident for a service + +![PagerDuty plugin popup modal for creating an incident](doc/pd3.png) + +![PagerDuty plugin showing an active incident](doc/pd4.png) ## Setup instructions diff --git a/plugins/pagerduty/doc/pd1.png b/plugins/pagerduty/doc/pd1.png new file mode 100644 index 0000000000..8242792b5a Binary files /dev/null and b/plugins/pagerduty/doc/pd1.png differ diff --git a/plugins/pagerduty/doc/pd2.png b/plugins/pagerduty/doc/pd2.png new file mode 100644 index 0000000000..816abd5e66 Binary files /dev/null and b/plugins/pagerduty/doc/pd2.png differ diff --git a/plugins/pagerduty/doc/pd3.png b/plugins/pagerduty/doc/pd3.png new file mode 100644 index 0000000000..89d7f1820d Binary files /dev/null and b/plugins/pagerduty/doc/pd3.png differ diff --git a/plugins/pagerduty/doc/pd4.png b/plugins/pagerduty/doc/pd4.png new file mode 100644 index 0000000000..70584bc59e Binary files /dev/null and b/plugins/pagerduty/doc/pd4.png differ diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index d56d162f03..eae99c1e0f 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -47,8 +47,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/proxy-backend/api-report.md b/plugins/proxy-backend/api-report.md index 1b1eba3b00..63469a2017 100644 --- a/plugins/proxy-backend/api-report.md +++ b/plugins/proxy-backend/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; // @public (undocumented) diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index e151c82ce9..9ef2d76266 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-register-component +## 0.2.18 + +### Patch Changes + +- b47fc34bc: Update "service catalog" references to "software catalog" +- Updated dependencies + - @backstage/plugin-catalog-react@0.2.5 + - @backstage/core-components@0.1.4 + ## 0.2.17 ### Patch Changes diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index cb4369ca84..30c7be5b0a 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.17", + "version": "0.2.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.2.4", + "@backstage/plugin-catalog-react": "^0.2.5", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,8 +46,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar-backend/api-report.md b/plugins/rollbar-backend/api-report.md index fa8c67d4f0..9fa525cfc3 100644 --- a/plugins/rollbar-backend/api-report.md +++ b/plugins/rollbar-backend/api-report.md @@ -6,7 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -20,7 +20,7 @@ export function getRequestHeaders(token: string): { // @public (undocumented) export class RollbarApi { - constructor(accessToken: string, logger: Logger); + constructor(accessToken: string, logger: Logger_2); // (undocumented) getActivatedCounts(projectName: string, options?: { environment: string; @@ -49,7 +49,7 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; // (undocumented) rollbarApi?: RollbarApi; } diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index e14f6f5a6c..ad3c04698d 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -39,7 +39,7 @@ "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "helmet": "^4.0.0", "lodash": "^4.17.15", "morgan": "^1.10.0", diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 68be84b55d..14945abf50 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -48,8 +48,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend-module-rails/.eslintrc.js b/plugins/scaffolder-backend-module-rails/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/scaffolder-backend-module-rails/README.md b/plugins/scaffolder-backend-module-rails/README.md new file mode 100644 index 0000000000..4ac52be941 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/README.md @@ -0,0 +1,247 @@ +# scaffolder-backend-module-rails + +Welcome to the Rails Module for Scaffolder. + +Here you can find all Rails related features to improve your scaffolder: + +- Rails Action to use the `new` command +- More features are coming + +## Getting started + +You need to configure the action in your backend: + +## From your Backstage root directory + +``` +cd packages/backend +yarn add @backstage/plugin-scaffolder-backend-module-rails +``` + +Configure the action (you can check +the [docs](https://backstage.io/docs/features/software-templates/writing-custom-actions#registering-custom-actions) to +see all options): + +```typescript +const actions = [ + createFetchRailsAction({ + integrations, + reader, + containerRunner, + }), +]; + +return await createRouter({ + containerRunner, + logger, + config, + database, + catalogClient, + reader, + actions, +}); +``` + +After that you can use the action in your template: + +```yaml +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: rails-demo + title: Rails template + description: scaffolder Rails app +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Fill in some steps + required: + - name + - owner + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + system: + title: System + type: string + description: System of the component + ui:field: EntityPicker + ui:options: + allowedKinds: + - System + defaultKind: System + + - title: Choose a location + required: + - repoUrl + - dryRun + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + dryRun: + title: Only perform a dry run, don't publish anything + type: boolean + default: false + railsArguments: + title: arguments to run the rails new command + type: object + properties: + minimal: + title: minimal + description: Preconfigure a minimal rails app + type: boolean + skipBundle: + title: skipBundle + description: Don't run bundle install + type: boolean + skipWebpackInstall: + title: skipWebpackInstall + description: Don't run Webpack install + type: boolean + api: + title: api + description: Preconfigure smaller stack for API only apps + type: boolean + template: + title: template + description: Path to some application template (can be a filesystem path or URL) + type: string + default: './rails-template-file.rb' + webpacker: + title: webpacker + description: + 'Preconfigure Webpack with a particular framework (options: react, + vue, angular, elm, stimulus)' + type: string + enum: + - react + - vue + - angular + - elm + - stimulus + database: + title: database + description: 'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)' + type: string + enum: + - mysql + - postgresql + - sqlite3 + - oracle + - sqlserver + - jdbcmysql + - jdbcsqlite3 + - jdbcpostgresql + - jdbc + railsVersion: + title: Rails version in Gemfile + description: + 'Set up the application with Gemfile pointing to a specific version + (options: dev, edge, master)' + type: string + enum: + - dev + - edge + - master + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:rails + input: + url: ./template + values: + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + system: '{{ parameters.system }}' + railsArguments: '{{ json parameters.railsArguments }}' + + - name: Write Catalog information + action: catalog:write + input: + component: + apiVersion: 'backstage.io/v1alpha1' + kind: Component + metadata: + name: '{{ parameters.name }}' + annotations: + github.com/project-slug: '{{ projectSlug parameters.repoUrl }}' + spec: + type: service + lifecycle: production + owner: '{{ parameters.owner }}' + + - id: publish + if: '{{ not parameters.dryRun }}' + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + - id: register + if: '{{ not parameters.dryRun }}' + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' + + - name: Results + if: '{{ parameters.dryRun }}' + action: debug:log + input: + listWorkspace: true + + output: + links: + - title: Repository + url: '{{ steps.publish.output.remoteUrl }}' + - title: Open in catalog + icon: 'catalog' + entityRef: '{{ steps.register.output.entityRef }}' +``` + +### What you need to run that action + +The environment need to have a [rails](https://github.com/rails/rails#getting-started) installation, or you can build and provide a docker image in your template. + +We have a [Dockerfile](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend-module-rails/Rails.dockerfile) that you can use to build your image. + +If you choose to provide a docker image, you need to update your template with `imageName` parameter: + +```yaml +steps: + - id: fetch-base + name: Fetch Base + action: fetch:rails + input: + url: ./template + imageName: repository/rails:tag + values: + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + system: '{{ parameters.system }}' + railsArguments: '{{ json parameters.railsArguments }}' +``` diff --git a/plugins/scaffolder-backend-module-rails/Rails.dockerfile b/plugins/scaffolder-backend-module-rails/Rails.dockerfile new file mode 100644 index 0000000000..0774fdc975 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/Rails.dockerfile @@ -0,0 +1,7 @@ +FROM ruby:3.0 + +RUN apt-get update -qq && \ + apt-get install -y nodejs postgresql-client git && \ + rm -rf /var/lib/apt/lists/ + +RUN gem install rails diff --git a/plugins/scaffolder-backend-module-rails/api-report.md b/plugins/scaffolder-backend-module-rails/api-report.md new file mode 100644 index 0000000000..95d9c1bd9f --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-scaffolder-backend-module-rails" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ContainerRunner } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export function createFetchRailsAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; + containerRunner: ContainerRunner; +}): TemplateAction; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json new file mode 100644 index 0000000000..b513100f16 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-scaffolder-backend-module-rails", + "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" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.8.3", + "@backstage/plugin-scaffolder-backend": "^0.12.2", + "@backstage/config": "^0.1.5", + "@backstage/errors": "^0.1.1", + "@backstage/integration": "^0.5.6", + "command-exists": "^1.2.9", + "fs-extra": "^9.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.1", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/command-exists": "^1.2.0", + "@types/fs-extra": "^9.0.1", + "@types/mock-fs": "^4.13.0", + "jest-when": "^3.1.0", + "mock-fs": "^4.13.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts similarity index 85% rename from plugins/scaffolder-backend/src/scaffolder/jobs/index.ts rename to plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts index 8ebffa2026..7d590c9a1a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './processor'; -export * from './types'; +export * from './rails'; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts new file mode 100644 index 0000000000..6b98dd86c6 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const mockRailsTemplater = { run: jest.fn() }; +jest.mock('@backstage/plugin-scaffolder-backend', () => ({ + ...jest.requireActual('@backstage/plugin-scaffolder-backend'), + fetchContents: jest.fn(), +})); +jest.mock('./railsNewRunner', () => { + return { + RailsNewRunner: jest.fn().mockImplementation(() => { + return mockRailsTemplater; + }), + }; +}); + +import { + ContainerRunner, + getVoidLogger, + UrlReader, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import mockFs from 'mock-fs'; +import os from 'os'; +import { resolve as resolvePath } from 'path'; +import { PassThrough } from 'stream'; +import { createFetchRailsAction } from './index'; +import { fetchContents } from '@backstage/plugin-scaffolder-backend'; + +describe('fetch:rails', () => { + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [ + { host: 'dev.azure.com', token: 'tokenlols' }, + { host: 'myazurehostnotoken.com' }, + ], + }, + }), + ); + + const mockTmpDir = os.tmpdir(); + const mockContext = { + input: { + url: 'https://rubyonrails.org/generator', + targetPath: 'something', + values: { + help: 'me', + }, + }, + baseUrl: 'somebase', + workspacePath: mockTmpDir, + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), + }; + + const mockReader: UrlReader = { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + }; + const containerRunner: ContainerRunner = { + runContainer: jest.fn(), + }; + + const action = createFetchRailsAction({ + integrations, + reader: mockReader, + containerRunner, + }); + + beforeEach(() => { + mockFs({ [`${mockContext.workspacePath}/result`]: {} }); + jest.restoreAllMocks(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should call fetchContents with the correct values', async () => { + await action.handler(mockContext); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it('should execute the rails templater with the correct values', async () => { + await action.handler(mockContext); + + expect(mockRailsTemplater.run).toHaveBeenCalledWith({ + workspacePath: mockTmpDir, + logStream: mockContext.logStream, + values: mockContext.input.values, + }); + }); + + it('should execute the rails templater with optional inputs if they are present and valid', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + imageName: 'foo/rails-custom-image', + }, + }); + + expect(mockRailsTemplater.run).toHaveBeenCalledWith({ + workspacePath: mockTmpDir, + logStream: mockContext.logStream, + values: { + ...mockContext.input.values, + imageName: 'foo/rails-custom-image', + }, + }); + }); + + it('should throw if the target directory is outside of the workspace path', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + targetPath: '/foo', + }, + }), + ).rejects.toThrow( + /targetPath may not specify a path outside the working directory/, + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts new file mode 100644 index 0000000000..e2dc8fd95a --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ContainerRunner, UrlReader } from '@backstage/backend-common'; +import { JsonObject } from '@backstage/config'; +import { InputError } from '@backstage/errors'; +import { ScmIntegrations } from '@backstage/integration'; +import fs from 'fs-extra'; +import { + createTemplateAction, + fetchContents, +} from '@backstage/plugin-scaffolder-backend'; + +import { resolve as resolvePath } from 'path'; +import { RailsNewRunner } from './railsNewRunner'; + +export function createFetchRailsAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; + containerRunner: ContainerRunner; +}) { + const { reader, integrations, containerRunner } = options; + + return createTemplateAction<{ + url: string; + targetPath?: string; + values: JsonObject; + imageName?: string; + }>({ + id: 'fetch:rails', + description: + 'Downloads a template from the given URL into the workspace, and runs a rails new generator on it.', + schema: { + input: { + type: 'object', + required: ['url'], + properties: { + url: { + title: 'Fetch URL', + description: + 'Relative path or absolute URL pointing to the directory tree to fetch', + type: 'string', + }, + targetPath: { + title: 'Target Path', + description: + 'Target path within the working directory to download the contents to.', + type: 'string', + }, + values: { + title: 'Template Values', + description: 'Values to pass on to rails for templating', + type: 'object', + properties: { + railsArguments: { + title: 'Arguments to pass to new command', + description: + 'You can provide some arguments to create a custom app', + type: 'object', + properties: { + minimal: { + title: 'minimal', + description: 'Preconfigure a minimal rails app', + type: 'boolean', + }, + skipBundle: { + title: 'skipBundle', + description: "Don't run bundle install", + type: 'boolean', + }, + skipWebpackInstall: { + title: 'skipWebpackInstall', + description: "Don't run Webpack install", + type: 'boolean', + }, + api: { + title: 'api', + description: 'Preconfigure smaller stack for API only apps', + type: 'boolean', + }, + template: { + title: 'template', + description: + 'Path to some application template (can be a filesystem path or URL)', + type: 'string', + }, + webpacker: { + title: 'webpacker', + description: + 'Preconfigure Webpack with a particular framework (options: react, vue, angular, elm, stimulus)', + type: 'string', + enum: ['react', 'vue', 'angular', 'elm', 'stimulus'], + }, + database: { + title: 'database', + description: + 'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)', + type: 'string', + enum: [ + 'mysql', + 'postgresql', + 'sqlite3', + 'oracle', + 'sqlserver', + 'jdbcmysql', + 'jdbcsqlite3', + 'jdbcpostgresql', + 'jdbc', + ], + }, + railsVersion: { + title: 'Rails version in Gemfile', + description: + 'Set up the application with Gemfile pointing to a specific version (options: fromImage, dev, edge, master)', + type: 'string', + enum: ['dev', 'edge', 'master', 'fromImage'], + }, + }, + }, + }, + }, + imageName: { + title: 'Rails Docker image', + description: + 'Specify a Docker image to run rails new. Used only when a local rails is not found.', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + ctx.logger.info('Fetching and then templating using rails'); + + const workDir = await ctx.createTemporaryDirectory(); + const resultDir = resolvePath(workDir, 'result'); + + await fetchContents({ + reader, + integrations, + baseUrl: ctx.baseUrl, + fetchUrl: ctx.input.url, + outputPath: workDir, + }); + + const templateRunner = new RailsNewRunner({ containerRunner }); + + const values = { + ...ctx.input.values, + imageName: ctx.input.imageName, + }; + + // Will execute the template in ./template and put the result in ./result + await templateRunner.run({ + workspacePath: workDir, + logStream: ctx.logStream, + values, + }); + + // Finally move the template result into the task workspace + const targetPath = ctx.input.targetPath ?? './'; + const outputPath = resolvePath(ctx.workspacePath, targetPath); + if (!outputPath.startsWith(ctx.workspacePath)) { + throw new InputError( + `Fetch action targetPath may not specify a path outside the working directory`, + ); + } + await fs.copy(resultDir, outputPath); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts new file mode 100644 index 0000000000..99a113f4f7 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { railsArgumentResolver } from './railsArgumentResolver'; +import { sep as separatorPath } from 'path'; +import os from 'os'; + +describe('railsArgumentResolver', () => { + describe('when provide the parameter', () => { + const root = os.platform() === 'win32' ? 'C:\\' : '/'; + test.each([ + [{}, []], + [{ minimal: true }, ['--minimal']], + [{ api: true }, ['--api']], + [{ skipBundle: true }, ['--skip-bundle']], + [{ skipWebpackInstall: true }, ['--skip-webpack-install']], + [{ webpacker: 'vue' }, ['--webpack', 'vue']], + [{ database: 'postgresql' }, ['--database', 'postgresql']], + [{ railsVersion: 'dev' }, ['--dev']], + [ + { template: `.${separatorPath}rails.rb` }, + ['--template', `${root}${separatorPath}rails.rb`], + ], + ])( + 'should include the argument to execution %p -> %p', + (passedArguments: object, expected: Array) => { + // that step is to ensure the validation between the TemplaterValues and the resolver + const values = { + owner: 'r', + storePath: '', + railsArguments: passedArguments, + }; + + const { railsArguments } = values; + + const argumentsToRun = railsArgumentResolver(root, railsArguments); + + expect(argumentsToRun).toEqual(expected); + }, + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts new file mode 100644 index 0000000000..b1ddadc6d9 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { sep as separatorPath } from 'path'; + +enum Webpacker { + react = 'react', + vue = 'vue', + angular = 'angular', + elm = 'elm', + stimulus = 'stimulus', +} + +enum Database { + mysql = 'mysql', + postgresql = 'postgresql', + sqlite3 = 'sqlite3', + oracle = 'oracle', + sqlserver = 'sqlserver', + jdbcmysql = 'jdbcmysql', + jdbcsqlite3 = 'jdbcsqlite3', + jdbcpostgresql = 'jdbcpostgresql', + jdbc = 'jdbc', +} + +enum RailsVersion { + dev = 'dev', + edge = 'edge', + master = 'master', + fromImage = 'fromImage', +} + +export type RailsRunOptions = { + minimal?: boolean; + api?: boolean; + template?: string; + webpacker?: Webpacker; + database?: Database; + railsVersion?: RailsVersion; + skipBundle?: boolean; + skipWebpackInstall?: boolean; +}; + +export const railsArgumentResolver = ( + projectRoot: string, + options: RailsRunOptions, +): string[] => { + const argumentsToRun: string[] = []; + + if (options?.minimal) { + argumentsToRun.push('--minimal'); + } + + if (options?.api) { + argumentsToRun.push('--api'); + } + + if (options?.skipBundle) { + argumentsToRun.push('--skip-bundle'); + } + + if (options?.skipWebpackInstall) { + argumentsToRun.push('--skip-webpack-install'); + } + + if ( + options?.webpacker && + Object.values(Webpacker).includes(options?.webpacker as Webpacker) + ) { + argumentsToRun.push('--webpack'); + argumentsToRun.push(options.webpacker); + } + + if ( + options?.database && + Object.values(Database).includes(options?.database as Database) + ) { + argumentsToRun.push('--database'); + argumentsToRun.push(options.database); + } + + if ( + options?.railsVersion !== RailsVersion.fromImage && + Object.values(RailsVersion).includes(options?.railsVersion as RailsVersion) + ) { + argumentsToRun.push(`--${options.railsVersion}`); + } + + if (options?.template) { + argumentsToRun.push('--template'); + argumentsToRun.push( + options.template.replace( + `.${separatorPath}`, + `${projectRoot}${separatorPath}`, + ), + ); + } + + return argumentsToRun; +}; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts new file mode 100644 index 0000000000..dc3f824236 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const runCommand = jest.fn(); +const commandExists = jest.fn(); + +jest.mock('@backstage/plugin-scaffolder-backend', () => ({ runCommand })); +jest.mock('command-exists', () => commandExists); +jest.mock('fs-extra'); + +import { ContainerRunner } from '@backstage/backend-common'; +import fs from 'fs-extra'; + +import path from 'path'; +import { PassThrough } from 'stream'; + +import { RailsNewRunner } from './railsNewRunner'; + +describe('Rails Templater', () => { + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when running on docker', () => { + it('should run the correct bindings for the volumes', async () => { + const logStream = new PassThrough(); + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + jest + .spyOn(fs, 'realpath') + .mockImplementation(x => Promise.resolve(x.toString())); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream, + }); + + expect(containerRunner.runContainer).toHaveBeenCalledWith({ + imageName: 'foo/rails-custom-image', + command: 'rails', + args: ['new', '/output/rails-project'], + envVars: { HOME: '/tmp' }, + mountDirs: { + ['tempdir']: '/input', + [path.join('tempdir', 'intermediate')]: '/output', + }, + workingDir: '/input', + logStream: logStream, + }); + }); + + it('should use the provided imageName', async () => { + const logStream = new PassThrough(); + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream, + }); + + expect(containerRunner.runContainer).toHaveBeenCalledWith( + expect.objectContaining({ + imageName: 'foo/rails-custom-image', + }), + ); + }); + + it('should pass through the streamer to the run docker helper', async () => { + const stream = new PassThrough(); + + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream: stream, + }); + + expect(containerRunner.runContainer).toHaveBeenCalledWith({ + imageName: 'foo/rails-custom-image', + command: 'rails', + args: ['new', '/output/rails-project'], + envVars: { HOME: '/tmp' }, + mountDirs: { + ['tempdir']: '/input', + [path.join('tempdir', 'intermediate')]: '/output', + }, + workingDir: '/input', + logStream: stream, + }); + }); + + it('update the template path to correct location', async () => { + const logStream = new PassThrough(); + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + railsArguments: { template: './something.rb' }, + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + jest + .spyOn(fs, 'realpath') + .mockImplementation(x => Promise.resolve(x.toString())); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream, + }); + + expect(containerRunner.runContainer).toHaveBeenCalledWith({ + imageName: 'foo/rails-custom-image', + command: 'rails', + args: [ + 'new', + '/output/rails-project', + '--template', + '/input/something.rb', + ], + envVars: { HOME: '/tmp' }, + mountDirs: { + ['tempdir']: '/input', + [path.join('tempdir', 'intermediate')]: '/output', + }, + workingDir: '/input', + logStream: logStream, + }); + }); + }); + + describe('when rails is available', () => { + it('use the binary', async () => { + const stream = new PassThrough(); + + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + commandExists.mockImplementationOnce(() => () => true); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream: stream, + }); + + expect(runCommand).toHaveBeenCalledWith({ + command: 'rails', + args: expect.arrayContaining([ + 'new', + path.join('tempdir', 'intermediate', 'rails-project'), + ]), + logStream: stream, + }); + }); + it('update the template path to correct location', async () => { + const stream = new PassThrough(); + + const values = { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + railsArguments: { template: './something.rb' }, + imageName: 'foo/rails-custom-image', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + commandExists.mockImplementationOnce(() => () => true); + + const templater = new RailsNewRunner({ containerRunner }); + await templater.run({ + workspacePath: 'tempdir', + values, + logStream: stream, + }); + + expect(runCommand).toHaveBeenCalledWith({ + command: 'rails', + args: expect.arrayContaining([ + 'new', + path.join('tempdir', 'intermediate', 'rails-project'), + '--template', + path.join('tempdir', './something.rb'), + ]), + logStream: stream, + }); + }); + }); + + describe('when nothing was generated', () => { + it('throws an error', async () => { + const stream = new PassThrough(); + + jest + .spyOn(fs, 'readdir') + .mockImplementationOnce(() => Promise.resolve([])); + + const templater = new RailsNewRunner({ containerRunner }); + await expect( + templater.run({ + workspacePath: 'tempdir', + values: { + owner: 'angeliski', + storePath: 'https://github.com/angeliski/rails-project', + name: 'rails-project', + imageName: 'foo/rails-custom-image', + }, + logStream: stream, + }), + ).rejects.toThrow(/No data generated by rails/); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts similarity index 52% rename from plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts rename to plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts index ef51fc20a9..3e18490587 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,76 +15,73 @@ */ import { ContainerRunner } from '@backstage/backend-common'; -import { JsonValue } from '@backstage/config'; -import commandExists from 'command-exists'; import fs from 'fs-extra'; import path from 'path'; -import { runCommand } from './helpers'; -import { TemplaterBase, TemplaterRunOptions } from './types'; +import { runCommand } from '@backstage/plugin-scaffolder-backend'; +import commandExists from 'command-exists'; +import { + railsArgumentResolver, + RailsRunOptions, +} from './railsArgumentResolver'; +import { JsonObject } from '@backstage/config'; +import { Writable } from 'stream'; -export class CookieCutter implements TemplaterBase { +export class RailsNewRunner { private readonly containerRunner: ContainerRunner; constructor({ containerRunner }: { containerRunner: ContainerRunner }) { this.containerRunner = containerRunner; } - private async fetchTemplateCookieCutter( - directory: string, - ): Promise> { - try { - return await fs.readJSON(path.join(directory, 'cookiecutter.json')); - } catch (ex) { - if (ex.code !== 'ENOENT') { - throw ex; - } - - return {}; - } - } - public async run({ workspacePath, values, logStream, - }: TemplaterRunOptions): Promise { - const templateDir = path.join(workspacePath, 'template'); + }: { + workspacePath: string; + values: JsonObject; + logStream: Writable; + }): Promise { const intermediateDir = path.join(workspacePath, 'intermediate'); await fs.ensureDir(intermediateDir); const resultDir = path.join(workspacePath, 'result'); - // First lets grab the default cookiecutter.json file - const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); - - const { imageName, ...valuesForCookieCutterJson } = values; - const cookieInfo = { - ...cookieCutterJson, - ...valuesForCookieCutterJson, - }; - - await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); + const { name, imageName, railsArguments } = values; // Directories to bind on container const mountDirs = { - [templateDir]: '/input', + [workspacePath]: '/input', [intermediateDir]: '/output', }; - // the command-exists package returns `true` or throws an error - const cookieCutterInstalled = await commandExists('cookiecutter').catch( - () => false, - ); - if (cookieCutterInstalled) { + const baseCommand = 'rails'; + const baseArguments = ['new']; + const commandExistsToRun = await commandExists(baseCommand); + + if (commandExistsToRun) { + const arrayExtraArguments = railsArgumentResolver( + workspacePath, + railsArguments as RailsRunOptions, + ); + await runCommand({ - command: 'cookiecutter', - args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'], + command: baseCommand, + args: [ + ...baseArguments, + `${intermediateDir}/${name}`, + ...arrayExtraArguments, + ], logStream, }); } else { + const arrayExtraArguments = railsArgumentResolver( + '/input', + railsArguments as RailsRunOptions, + ); await this.containerRunner.runContainer({ - imageName: imageName || 'spotify/backstage-cookiecutter', - command: 'cookiecutter', - args: ['--no-input', '-o', '/output', '/input', '--verbose'], + imageName: imageName as string, + command: baseCommand, + args: [...baseArguments, `/output/${name}`, ...arrayExtraArguments], mountDirs, workingDir: '/input', // Set the home directory inside the container as something that applications can @@ -94,12 +91,12 @@ export class CookieCutter implements TemplaterBase { }); } - // if cookiecutter was successful, intermediateDir will contain + // if command was successful, intermediateDir should contain // exactly one directory. const [generated] = await fs.readdir(intermediateDir); if (generated === undefined) { - throw new Error('No data generated by cookiecutter'); + throw new Error(`No data generated by ${baseCommand}`); } await fs.move(path.join(intermediateDir, generated), resultDir); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/index.ts similarity index 79% rename from plugins/scaffolder-backend/src/scaffolder/stages/index.ts rename to plugins/scaffolder-backend-module-rails/src/actions/index.ts index c6f824a139..1b47db9e03 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './prepare'; -export * from './publish'; -export * from './templater'; -export * from './helpers'; +export * from './fetch'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts b/plugins/scaffolder-backend-module-rails/src/index.ts similarity index 76% rename from plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts rename to plugins/scaffolder-backend-module-rails/src/index.ts index 4d80f86946..4f06b14a86 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,8 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './cookiecutter'; -export * from './types'; -export * from './helpers'; -export * from './templaters'; -export * from './cra'; +export * from './actions'; diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 1a843931a1..7d7f4e7dbe 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-scaffolder-backend +## 0.12.4 + +### Patch Changes + +- 1627daac2: Fix `catalog:write` on windows systems +- ab5cc376f: Use new utilities from `@backstage/backend-common` for safely resolving child paths +- Updated dependencies + - @backstage/backend-common@0.8.4 + - @backstage/integration@0.5.7 + - @backstage/catalog-client@0.3.15 + ## 0.12.3 ### Patch Changes diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 792913749b..e8e8697291 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -4,26 +4,18 @@ ```ts -import { AzureIntegrationConfig } from '@backstage/integration'; -import { BitbucketIntegrationConfig } from '@backstage/integration'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { ContainerRunner } from '@backstage/backend-common'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; import express from 'express'; -import { GithubCredentialsProvider } from '@backstage/integration'; -import { GitHubIntegrationConfig } from '@backstage/integration'; -import { Gitlab } from '@gitbeaker/core'; -import { GitLabIntegrationConfig } from '@backstage/integration'; -import gitUrlParse from 'git-url-parse'; import { JsonObject } from '@backstage/config'; import { JsonValue } from '@backstage/config'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; @@ -31,7 +23,7 @@ import { Writable } from 'stream'; // @public (undocumented) export type ActionContext = { baseUrl?: string; - logger: Logger; + logger: Logger_2; logStream: Writable; token?: string | undefined; workspacePath: string; @@ -40,74 +32,12 @@ export type ActionContext = { createTemporaryDirectory(): Promise; }; -// @public (undocumented) -export class AzurePreparer implements PreparerBase { - constructor(config: { - token?: string; - }); - // (undocumented) - static fromConfig(config: AzureIntegrationConfig): AzurePreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @public (undocumented) -export class AzurePublisher implements PublisherBase { - constructor(config: { - token: string; - }); - // (undocumented) - static fromConfig(config: AzureIntegrationConfig): Promise; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @public (undocumented) -export class BitbucketPreparer implements PreparerBase { - constructor(config: { - username?: string; - token?: string; - appPassword?: string; - }); - // (undocumented) - static fromConfig(config: BitbucketIntegrationConfig): BitbucketPreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @public (undocumented) -export class BitbucketPublisher implements PublisherBase { - constructor(config: { - host: string; - token?: string; - appPassword?: string; - username?: string; - apiBaseUrl?: string; - repoVisibility: RepoVisibilityOptions_2; - }); - // (undocumented) - static fromConfig(config: BitbucketIntegrationConfig, { repoVisibility }: { - repoVisibility: RepoVisibilityOptions_2; - }): Promise; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - // @public export class CatalogEntityClient { constructor(catalogClient: CatalogApi); findTemplate(templateName: string, options?: { token?: string; - }): Promise; -} - -// @public (undocumented) -export class CookieCutter implements TemplaterBase { - constructor({ containerRunner }: { - containerRunner: ContainerRunner; - }); - // (undocumented) - run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; + }): Promise; } // @public (undocumented) @@ -115,7 +45,8 @@ export const createBuiltinActions: (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; + config: Config; }) => TemplateAction[]; // @public (undocumented) @@ -134,7 +65,7 @@ export function createDebugLogAction(): TemplateAction; export function createFetchCookiecutterAction(options: { reader: UrlReader; integrations: ScmIntegrations; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; }): TemplateAction; // @public (undocumented) @@ -144,16 +75,21 @@ export function createFetchPlainAction(options: { }): TemplateAction; // @public (undocumented) -export function createLegacyActions(options: Options): TemplateAction[]; +export const createFilesystemDeleteAction: () => TemplateAction; + +// @public (undocumented) +export const createFilesystemRenameAction: () => TemplateAction; // @public (undocumented) export function createPublishAzureAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public (undocumented) export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public @@ -162,6 +98,7 @@ export function createPublishFileAction(): TemplateAction; // @public (undocumented) export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public (undocumented) @@ -170,17 +107,9 @@ export const createPublishGithubPullRequestAction: ({ integrations, clientFactor // @public (undocumented) export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; -// @public (undocumented) -export class CreateReactAppTemplater implements TemplaterBase { - constructor({ containerRunner }: { - containerRunner: ContainerRunner; - }); - // (undocumented) - run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; -} - // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -190,204 +119,13 @@ export const createTemplateAction: >(templateAction: TemplateAction) => TemplateAction; // @public (undocumented) -export class FilePreparer implements PreparerBase { - // (undocumented) - prepare({ url, workspacePath }: PreparerOptions): Promise; -} - -// @public -export const getTemplaterKey: (entity: TemplateEntityV1alpha1) => string; - -// @public (undocumented) -export class GithubPreparer implements PreparerBase { - constructor(config: { - credentialsProvider: GithubCredentialsProvider; - }); - // (undocumented) - static fromConfig(config: GitHubIntegrationConfig): GithubPreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @public @deprecated (undocumented) -export class GithubPublisher implements PublisherBase { - constructor(config: { - credentialsProvider: GithubCredentialsProvider; - repoVisibility: RepoVisibilityOptions; - apiBaseUrl: string | undefined; - }); - // (undocumented) - static fromConfig(config: GitHubIntegrationConfig, { repoVisibility }: { - repoVisibility: RepoVisibilityOptions; - }): Promise; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @public (undocumented) -export class GitlabPreparer implements PreparerBase { - constructor(config: { - token?: string; - }); - // (undocumented) - static fromConfig(config: GitLabIntegrationConfig): GitlabPreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @public (undocumented) -export class GitlabPublisher implements PublisherBase { - constructor(config: { - token: string; - client: Gitlab; - repoVisibility: RepoVisibilityOptions_3; - }); - // (undocumented) - static fromConfig(config: GitLabIntegrationConfig, { repoVisibility }: { - repoVisibility: RepoVisibilityOptions_3; - }): Promise; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @public (undocumented) -export type Job = { - id: string; - context: StageContext; - status: ProcessorStatus; - stages: StageResult[]; - error?: Error; -}; - -// @public (undocumented) -export type JobAndDirectoryTuple = { - job: Job; - directory: string; -}; - -// @public (undocumented) -export class JobProcessor implements Processor { - constructor(workingDirectory: string); - // (undocumented) - create({ entity, values, stages, }: { - entity: TemplateEntityV1alpha1; - values: TemplaterValues; - stages: StageInput[]; - }): Job; - // (undocumented) - static fromConfig({ config, logger, }: { - config: Config; - logger: Logger; - }): Promise; - // (undocumented) - get(id: string): Job | undefined; - // (undocumented) - run(job: Job): Promise; - } - -// @public (undocumented) -export function joinGitUrlPath(repoUrl: string, path?: string): string; - -// @public (undocumented) -export type ParsedLocationAnnotation = { - protocol: 'file' | 'url'; - location: string; -}; - -// @public (undocumented) -export const parseLocationAnnotation: (entity: TemplateEntityV1alpha1) => ParsedLocationAnnotation; - -// @public (undocumented) -export interface PreparerBase { - prepare(opts: PreparerOptions): Promise; -} - -// @public (undocumented) -export type PreparerBuilder = { - register(host: string, preparer: PreparerBase): void; - get(url: string): PreparerBase; -}; - -// @public (undocumented) -export type PreparerOptions = { - url: string; - workspacePath: string; - logger: Logger; -}; - -// @public (undocumented) -export class Preparers implements PreparerBuilder { - // (undocumented) - static fromConfig(config: Config, _: { - logger: Logger; - }): Promise; - // (undocumented) - get(url: string): PreparerBase; - // (undocumented) - register(host: string, preparer: PreparerBase): void; -} - -// @public (undocumented) -export type Processor = { - create({ entity, values, stages, }: { - entity: TemplateEntityV1alpha1; - values: TemplaterValues; - stages: StageInput[]; - }): Job; - get(id: string): Job | undefined; - run(job: Job): Promise; -}; - -// @public (undocumented) -export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; - -// @public -export type PublisherBase = { - publish(opts: PublisherOptions): Promise; -}; - -// @public (undocumented) -export type PublisherBuilder = { - register(host: string, publisher: PublisherBase): void; - get(storePath: string): PublisherBase; -}; - -// @public (undocumented) -export type PublisherOptions = { - values: TemplaterValues; - workspacePath: string; - logger: Logger; -}; - -// @public (undocumented) -export type PublisherResult = { - remoteUrl: string; - catalogInfoUrl?: string; -}; - -// @public (undocumented) -export class Publishers implements PublisherBuilder { - // (undocumented) - static fromConfig(config: Config, _options: { - logger: Logger; - }): Promise; - // (undocumented) - get(url: string): PublisherBase; - // (undocumented) - register(host: string, preparer: PublisherBase | undefined): void; -} - -// @public (undocumented) -export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -// @public -export type RequiredTemplateValues = { - owner: string; - storePath: string; - destination?: { - git?: gitUrlParse.GitUrl; - }; -}; +export function fetchContents({ reader, integrations, baseUrl, fetchUrl, outputPath, }: { + reader: UrlReader; + integrations: ScmIntegrations; + baseUrl?: string; + fetchUrl?: JsonValue; + outputPath: string; +}): Promise; // @public (undocumented) export interface RouterOptions { @@ -398,63 +136,20 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) + containerRunner: ContainerRunner; + // (undocumented) database: PluginDatabaseManager; // (undocumented) - logger: Logger; - // (undocumented) - preparers: PreparerBuilder; - // (undocumented) - publishers: PublisherBuilder; + logger: Logger_2; // (undocumented) reader: UrlReader; // (undocumented) taskWorkers?: number; - // (undocumented) - templaters: TemplaterBuilder; } // @public (undocumented) export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise; -// @public (undocumented) -export type RunCommandOptions = { - command: string; - args: string[]; - logStream?: Writable; -}; - -// @public (undocumented) -export type StageContext = { - values: TemplaterValues; - entity: TemplateEntityV1alpha1; - logger: Logger; - logStream: Writable; - workspacePath: string; -} & T; - -// @public (undocumented) -export interface StageInput { - // (undocumented) - handler(ctx: StageContext): Promise; - // (undocumented) - name: string; -} - -// @public (undocumented) -export interface StageResult extends StageInput { - // (undocumented) - endedAt?: number; - // (undocumented) - log: string[]; - // (undocumented) - startedAt?: number; - // (undocumented) - status: ProcessorStatus; -} - -// @public -export type SupportedTemplatingKey = 'cookiecutter' | string; - // @public (undocumented) export type TemplateAction = { id: string; @@ -476,45 +171,6 @@ export class TemplateActionRegistry { register(action: TemplateAction): void; } -// @public (undocumented) -export type TemplaterBase = { - run(opts: TemplaterRunOptions): Promise; -}; - -// @public -export type TemplaterBuilder = { - register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(templater: string): TemplaterBase; -}; - -// @public (undocumented) -export type TemplaterConfig = { - templater?: TemplaterBase; -}; - -// @public -export type TemplaterRunOptions = { - workspacePath: string; - values: TemplaterValues; - logStream?: Writable; -}; - -// @public -export type TemplaterRunResult = { - resultDir: string; -}; - -// @public (undocumented) -export class Templaters implements TemplaterBuilder { - // (undocumented) - get(templaterId: string): TemplaterBase; - // (undocumented) - register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase): void; - } - -// @public (undocumented) -export type TemplaterValues = RequiredTemplateValues & Record; - // (No @packageDocumentation comment for this package) diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 971c3036d6..795fe5b112 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -17,6 +17,13 @@ export interface Config { /** Configuration options for the scaffolder plugin */ scaffolder?: { + /** + * The commit author info used when new components are created. + */ + defaultAuthor?: { + name?: string; + email?: string; + }; github?: { [key: string]: string; /** diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 1ad60b7248..7173f865c5 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.12.3", + "version": "0.12.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,25 +29,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.3", - "@backstage/catalog-client": "^0.3.14", + "@backstage/backend-common": "^0.8.4", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@gitbeaker/core": "^30.2.0", "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "@types/express": "^4.17.6", - "@types/git-url-parse": "^9.0.0", - "azure-devops-node-api": "^10.1.1", + "azure-devops-node-api": "^10.2.2", "command-exists": "^1.2.9", "compression": "^1.7.4", "cors": "^2.8.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "git-url-parse": "^11.4.4", "globby": "^11.0.0", "handlebars": "^4.7.6", @@ -64,10 +63,11 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@backstage/test-utils": "^0.1.14", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", + "@types/git-url-parse": "^9.0.0", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", "jest-when": "^3.1.0", diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml index 7b3d58d0b3..1e81242e7a 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -29,7 +29,6 @@ spec: title: Description type: string description: A description for the component - ui:autofocus: true ui:options: rows: 5 owner: diff --git a/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml new file mode 100644 index 0000000000..174c58741b --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml @@ -0,0 +1,174 @@ +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: rails-demo + title: Rails template + description: scaffolder Rails app +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Fill in some steps + required: + - name + - owner + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + system: + title: System + type: string + description: System of the component + ui:field: EntityPicker + ui:options: + allowedKinds: + - System + defaultKind: System + + - title: Choose a location + required: + - repoUrl + - dryRun + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + dryRun: + title: Only perform a dry run, don't publish anything + type: boolean + default: false + railsArguments: + title: arguments to run the rails new command + type: object + properties: + minimal: + title: minimal + description: Preconfigure a minimal rails app + type: boolean + skipBundle: + title: skipBundle + description: Don't run bundle install + type: boolean + skipWebpackInstall: + title: skipWebpackInstall + description: Don't run Webpack install + type: boolean + api: + title: api + description: Preconfigure smaller stack for API only apps + type: boolean + template: + title: template + description: Path to some application template (can be a filesystem path or URL) + type: string + default: './rails-template-file.rb' + webpacker: + title: webpacker + description: 'Preconfigure Webpack with a particular framework (options: react, + vue, angular, elm, stimulus)' + type: string + enum: + - react + - vue + - angular + - elm + - stimulus + database: + title: database + description: 'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)' + type: string + enum: + - mysql + - postgresql + - sqlite3 + - oracle + - sqlserver + - jdbcmysql + - jdbcsqlite3 + - jdbcpostgresql + - jdbc + railsVersion: + title: Rails version in Gemfile + description: 'Set up the application with Gemfile pointing to a specific version + (options: dev, edge, master)' + type: string + enum: + - dev + - edge + - master + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:rails + input: + url: ./template + values: + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + system: '{{ parameters.system }}' + railsArguments: '{{ json parameters.railsArguments }}' + + - name: Write Catalog information + action: catalog:write + input: + component: + apiVersion: 'backstage.io/v1alpha1' + kind: Component + metadata: + name: '{{ parameters.name }}' + annotations: + github.com/project-slug: '{{ projectSlug parameters.repoUrl }}' + spec: + type: service + lifecycle: production + owner: '{{ parameters.owner }}' + + - id: publish + if: '{{ not parameters.dryRun }}' + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + - id: register + if: '{{ not parameters.dryRun }}' + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' + + - name: Results + if: '{{ parameters.dryRun }}' + action: debug:log + input: + listWorkspace: true + + output: + links: + - title: Repository + url: '{{ steps.publish.output.remoteUrl }}' + - title: Open in catalog + icon: 'catalog' + entityRef: '{{ steps.register.output.entityRef }}' diff --git a/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb b/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb new file mode 100644 index 0000000000..90c0d53ddc --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb @@ -0,0 +1,14 @@ +gem_group :development, :test do + gem "rspec" + gem "rspec-rails" +end + +rakefile("example.rake") do + <<-TASK + namespace :example do + task :backstage do + puts "i like backstage!" + end + end + TASK +end diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 9f3a754485..430afa5b5d 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - TemplateEntityV1alpha1, - TemplateEntityV1beta2, -} from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { CatalogApi } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/errors'; @@ -35,7 +32,7 @@ export class CatalogEntityClient { async findTemplate( templateName: string, options?: { token?: string }, - ): Promise { + ): Promise { const { items: templates } = (await this.catalogClient.getEntities( { filter: { @@ -44,7 +41,7 @@ export class CatalogEntityClient { }, }, options, - )) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] }; + )) as { items: TemplateEntityV1beta2[] }; if (templates.length !== 1) { if (templates.length > 1) { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 598d36938e..08d7bbf824 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -14,16 +14,21 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { ContainerRunner, UrlReader } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ScmIntegrations } from '@backstage/integration'; -import { TemplaterBuilder } from '../../stages'; +import { Config } from '@backstage/config'; import { - createCatalogRegisterAction, createCatalogWriteAction, + createCatalogRegisterAction, } from './catalog'; + import { createDebugLogAction } from './debug'; import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch'; +import { + createFilesystemDeleteAction, + createFilesystemRenameAction, +} from './filesystem'; import { createPublishAzureAction, createPublishBitbucketAction, @@ -36,9 +41,16 @@ export const createBuiltinActions = (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; + config: Config; }) => { - const { reader, integrations, templaters, catalogClient } = options; + const { + reader, + integrations, + containerRunner, + catalogClient, + config, + } = options; return [ createFetchPlainAction({ @@ -48,25 +60,31 @@ export const createBuiltinActions = (options: { createFetchCookiecutterAction({ reader, integrations, - templaters, + containerRunner, }), createPublishGithubAction({ integrations, + config, }), createPublishGithubPullRequestAction({ integrations, }), createPublishGitlabAction({ integrations, + config, }), createPublishBitbucketAction({ integrations, + config, }), createPublishAzureAction({ integrations, + config, }), createDebugLogAction(), createCatalogRegisterAction({ catalogClient, integrations }), createCatalogWriteAction(), + createFilesystemDeleteAction(), + createFilesystemRenameAction(), ]; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts index 123f5e0ebb..86f953a6a5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts @@ -13,18 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers'); +const runCommand = jest.fn(); +const commandExists = jest.fn(); +const fetchContents = jest.fn(); -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; +jest.mock('./helpers', () => ({ fetchContents })); +jest.mock('command-exists', () => commandExists); +jest.mock('../helpers', () => ({ runCommand })); + +import { + getVoidLogger, + UrlReader, + ContainerRunner, +} from '@backstage/backend-common'; +import { ConfigReader, JsonObject } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; -import mock from 'mock-fs'; +import mockFs from 'mock-fs'; import os from 'os'; -import { resolve as resolvePath } from 'path'; import { PassThrough } from 'stream'; -import { Templaters } from '../../../stages/templater'; import { createFetchCookiecutterAction } from './cookiecutter'; -import { fetchContents } from './helpers'; +import { join } from 'path'; +import { ActionContext } from '../../types'; describe('fetch:cookiecutter', () => { const integrations = ScmIntegrations.fromConfig( @@ -38,23 +47,19 @@ describe('fetch:cookiecutter', () => { }), ); - const templaters = new Templaters(); - const cookiecutterTemplater = { run: jest.fn() }; const mockTmpDir = os.tmpdir(); - const mockContext = { - input: { - url: 'https://google.com/cookie/cutter', - targetPath: 'something', - values: { - help: 'me', - }, - }, - baseUrl: 'somebase', - workspacePath: mockTmpDir, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), + + let mockContext: ActionContext<{ + url: string; + targetPath?: string; + values: JsonObject; + copyWithoutRender?: string[]; + extensions?: string[]; + imageName?: string; + }>; + + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), }; const mockReader: UrlReader = { @@ -65,122 +70,143 @@ describe('fetch:cookiecutter', () => { const action = createFetchCookiecutterAction({ integrations, - templaters, + containerRunner, reader: mockReader, }); - templaters.register('cookiecutter', cookiecutterTemplater); - beforeEach(() => { - mock({ [`${mockContext.workspacePath}/result`]: {} }); - jest.restoreAllMocks(); + jest.resetAllMocks(); + + mockContext = { + input: { + url: 'https://google.com/cookie/cutter', + targetPath: 'something', + values: { + help: 'me', + }, + }, + baseUrl: 'somebase', + workspacePath: mockTmpDir, + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), + }; + + // mock the temp directory + mockFs({ [mockTmpDir]: {} }); + mockFs({ [`${join(mockTmpDir, 'template')}`]: {} }); + + commandExists.mockResolvedValue(null); + + // Mock when run container is called it creates some new files in the mock filesystem + containerRunner.runContainer.mockImplementation(async () => { + mockFs({ + [`${join(mockTmpDir, 'intermediate')}`]: { + 'testfile.json': '{}', + }, + }); + }); + + // Mock when runCommand is called it creats some new files in the mock filesystem + runCommand.mockImplementation(async () => { + mockFs({ + [`${join(mockTmpDir, 'intermediate')}`]: { + 'testfile.json': '{}', + }, + }); + }); }); afterEach(() => { - mock.restore(); + mockFs.restore(); }); - it('should call fetchContents with the correct values', async () => { - await action.handler(mockContext); + it('should throw an error when copyWithoutRender is not an array', async () => { + (mockContext.input as any).copyWithoutRender = 'not an array'; - expect(fetchContents).toHaveBeenCalledWith({ - reader: mockReader, - integrations, - baseUrl: mockContext.baseUrl, - fetchUrl: mockContext.input.url, - outputPath: resolvePath( - mockContext.workspacePath, - `template/{{cookiecutter and 'contents'}}`, - ), - }); - }); - - it('should execute the cookiecutter templater with the correct values', async () => { - await action.handler(mockContext); - - expect(cookiecutterTemplater.run).toHaveBeenCalledWith({ - workspacePath: mockTmpDir, - logStream: mockContext.logStream, - values: mockContext.input.values, - }); - }); - - it('should execute the cookiecutter templater with optional inputs if they are present and valid', async () => { - await action.handler({ - ...mockContext, - input: { - ...mockContext.input, - copyWithoutRender: ['goreleaser.yml'], - extensions: [ - 'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension', - ], - imageName: 'foo/cookiecutter-image-with-extensions', - }, - }); - - expect(cookiecutterTemplater.run).toHaveBeenCalledWith({ - workspacePath: mockTmpDir, - logStream: mockContext.logStream, - values: { - ...mockContext.input.values, - _copy_without_render: ['goreleaser.yml'], - _extensions: [ - 'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension', - ], - imageName: 'foo/cookiecutter-image-with-extensions', - }, - }); - }); - - it('should throw if copyWithoutRender is not an Array', async () => { - await expect( - action.handler({ - ...mockContext, - input: { - ...mockContext.input, - copyWithoutRender: 'xyz', - }, - }), - ).rejects.toThrow(/copyWithoutRender must be an Array/); - }); - - it('should throw if extensions is not an Array', async () => { - await expect( - action.handler({ - ...mockContext, - input: { - ...mockContext.input, - extensions: 'xyz', - }, - }), - ).rejects.toThrow(/extensions must be an Array/); - }); - - it('should throw if there is no cookiecutter templater initialized', async () => { - const templatersWithoutCookiecutter = new Templaters(); - - const newAction = createFetchCookiecutterAction({ - integrations, - templaters: templatersWithoutCookiecutter, - reader: mockReader, - }); - - await expect(newAction.handler(mockContext)).rejects.toThrow( - /No templater registered/, + await expect(action.handler(mockContext)).rejects.toThrowError( + /Fetch action input copyWithoutRender must be an Array/, ); }); - it('should throw if the target directory is outside of the workspace path', async () => { - await expect( - action.handler({ - ...mockContext, - input: { - ...mockContext.input, - targetPath: '/foo', - }, + it('should throw an error when extensions is not an array', async () => { + (mockContext.input as any).extensions = 'not an array'; + + await expect(action.handler(mockContext)).rejects.toThrowError( + /Fetch action input extensions must be an Array/, + ); + }); + + it('should call fetchContents with the correct variables', async () => { + fetchContents.mockImplementation(() => Promise.resolve()); + await action.handler(mockContext); + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + }); + + it('should call out to cookiecutter using runCommand when cookiecutter is installed', async () => { + commandExists.mockResolvedValue(true); + + await action.handler(mockContext); + + expect(runCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'cookiecutter', + args: [ + '--no-input', + '-o', + join(mockTmpDir, 'intermediate'), + join(mockTmpDir, 'template'), + '--verbose', + ], + logStream: mockContext.logStream, + }), + ); + }); + + it('should call out to the containerRunner when there is no cookiecutter installed', async () => { + commandExists.mockResolvedValue(false); + + await action.handler(mockContext); + + expect(containerRunner.runContainer).toHaveBeenCalledWith( + expect.objectContaining({ + imageName: 'spotify/backstage-cookiecutter', + command: 'cookiecutter', + args: ['--no-input', '-o', '/output', '/input', '--verbose'], + mountDirs: { + [join(mockTmpDir, 'intermediate')]: '/output', + [join(mockTmpDir, 'template')]: '/input', + }, + workingDir: '/input', + envVars: { HOME: '/tmp' }, + logStream: mockContext.logStream, + }), + ); + }); + + it('should use a custom imageName when there is an image supplied to the context', async () => { + const imageName = 'test-image'; + mockContext.input.imageName = imageName; + + await action.handler(mockContext); + + expect(containerRunner.runContainer).toHaveBeenCalledWith( + expect.objectContaining({ + imageName, }), - ).rejects.toThrow( - /targetPath may not specify a path outside the working directory/, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts index 9679adffa8..2bf22ce4fd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -14,22 +14,117 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; -import { JsonObject } from '@backstage/config'; +import { + ContainerRunner, + UrlReader, + resolveSafeChildPath, +} from '@backstage/backend-common'; +import { JsonObject, JsonValue } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; +import commandExists from 'command-exists'; import fs from 'fs-extra'; -import { resolve as resolvePath } from 'path'; -import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater'; +import path, { resolve as resolvePath } from 'path'; +import { Writable } from 'stream'; +import { runCommand } from '../helpers'; import { createTemplateAction } from '../../createTemplateAction'; import { fetchContents } from './helpers'; +export class CookiecutterRunner { + private readonly containerRunner: ContainerRunner; + + constructor({ containerRunner }: { containerRunner: ContainerRunner }) { + this.containerRunner = containerRunner; + } + + private async fetchTemplateCookieCutter( + directory: string, + ): Promise> { + try { + return await fs.readJSON(path.join(directory, 'cookiecutter.json')); + } catch (ex) { + if (ex.code !== 'ENOENT') { + throw ex; + } + + return {}; + } + } + + public async run({ + workspacePath, + values, + logStream, + }: { + workspacePath: string; + values: JsonObject; + logStream: Writable; + }): Promise { + const templateDir = path.join(workspacePath, 'template'); + const intermediateDir = path.join(workspacePath, 'intermediate'); + await fs.ensureDir(intermediateDir); + const resultDir = path.join(workspacePath, 'result'); + + // First lets grab the default cookiecutter.json file + const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); + + const { imageName, ...valuesForCookieCutterJson } = values; + const cookieInfo = { + ...cookieCutterJson, + ...valuesForCookieCutterJson, + }; + + await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); + + // Directories to bind on container + const mountDirs = { + [templateDir]: '/input', + [intermediateDir]: '/output', + }; + + // the command-exists package returns `true` or throws an error + const cookieCutterInstalled = await commandExists('cookiecutter').catch( + () => false, + ); + if (cookieCutterInstalled) { + await runCommand({ + command: 'cookiecutter', + args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'], + logStream, + }); + } else { + await this.containerRunner.runContainer({ + imageName: (imageName as string) ?? 'spotify/backstage-cookiecutter', + command: 'cookiecutter', + args: ['--no-input', '-o', '/output', '/input', '--verbose'], + mountDirs, + workingDir: '/input', + // Set the home directory inside the container as something that applications can + // write to, otherwise they will just fail trying to write to / + envVars: { HOME: '/tmp' }, + logStream, + }); + } + + // if cookiecutter was successful, intermediateDir will contain + // exactly one directory. + + const [generated] = await fs.readdir(intermediateDir); + + if (generated === undefined) { + throw new Error('No data generated by cookiecutter'); + } + + await fs.move(path.join(intermediateDir, generated), resultDir); + } +} + export function createFetchCookiecutterAction(options: { reader: UrlReader; integrations: ScmIntegrations; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; }) { - const { reader, templaters, integrations } = options; + const { reader, containerRunner, integrations } = options; return createTemplateAction<{ url: string; @@ -121,9 +216,9 @@ export function createFetchCookiecutterAction(options: { outputPath: templateContentsDir, }); - const cookiecutter = templaters.get('cookiecutter'); + const cookiecutter = new CookiecutterRunner({ containerRunner }); const values = { - ...(ctx.input.values as TemplaterValues), + ...ctx.input.values, _copy_without_render: ctx.input.copyWithoutRender, _extensions: ctx.input.extensions, imageName: ctx.input.imageName, @@ -138,12 +233,7 @@ export function createFetchCookiecutterAction(options: { // Finally move the template result into the task workspace const targetPath = ctx.input.targetPath ?? './'; - const outputPath = resolvePath(ctx.workspacePath, targetPath); - if (!outputPath.startsWith(ctx.workspacePath)) { - throw new InputError( - `Fetch action targetPath may not specify a path outside the working directory`, - ); - } + const outputPath = resolveSafeChildPath(ctx.workspacePath, targetPath); await fs.copy(resultDir, outputPath); }, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts index 8e0f93f3ab..fa6d23c032 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts @@ -16,3 +16,4 @@ export { createFetchPlainAction } from './plain'; export { createFetchCookiecutterAction } from './cookiecutter'; +export { fetchContents } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 2f6caf8c97..1a4428e3f0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -62,7 +62,7 @@ describe('fetch:plain', () => { }, }), ).rejects.toThrow( - /Fetch action targetPath may not specify a path outside the working directory/, + /Relative path is not allowed to refer to a directory outside its parent/, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index 8f7eefb657..9cac6d5ab8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import path from 'path'; -import { UrlReader } from '@backstage/backend-common'; -import { InputError } from '@backstage/errors'; +import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; import { fetchContents } from './helpers'; import { createTemplateAction } from '../../createTemplateAction'; @@ -56,12 +54,7 @@ export function createFetchPlainAction(options: { // Finally move the template result into the task workspace const targetPath = ctx.input.targetPath ?? './'; - const outputPath = path.resolve(ctx.workspacePath, targetPath); - if (!outputPath.startsWith(ctx.workspacePath)) { - throw new InputError( - `Fetch action targetPath may not specify a path outside the working directory`, - ); - } + const outputPath = resolveSafeChildPath(ctx.workspacePath, targetPath); await fetchContents({ reader, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts new file mode 100644 index 0000000000..ba8a145721 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as os from 'os'; +import mockFs from 'mock-fs'; +import { resolve as resolvePath } from 'path'; +import { createFilesystemDeleteAction } from './delete'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +import fs from 'fs-extra'; + +const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const workspacePath = resolvePath(root, 'my-workspace'); + +describe('fs:delete', () => { + const action = createFilesystemDeleteAction(); + + const mockContext = { + input: { + files: ['unit-test-a.js', 'unit-test-b.js'], + }, + workspacePath, + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + beforeEach(() => { + jest.restoreAllMocks(); + + mockFs({ + [workspacePath]: { + 'unit-test-a.js': 'hello', + 'unit-test-b.js': 'world', + 'a-folder': { + 'unit-test-in-a-folder.js2': 'content', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should throw an error when files is not an array', async () => { + await expect( + action.handler({ + ...mockContext, + input: { files: undefined }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: {} }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: '' }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: null }, + }), + ).rejects.toThrow(/files must be an Array/); + }); + + it('should throw when file name is not relative to the workspace', async () => { + await expect( + action.handler({ + ...mockContext, + input: { files: ['/foo/../../../index.js'] }, + }), + ).rejects.toThrow( + /Relative path is not allowed to refer to a directory outside its parent/, + ); + + await expect( + action.handler({ + ...mockContext, + input: { files: ['../../../index.js'] }, + }), + ).rejects.toThrow( + /Relative path is not allowed to refer to a directory outside its parent/, + ); + }); + + it('should call fs.rm with the correct values', async () => { + const files = ['unit-test-a.js', 'unit-test-b.js']; + + files.forEach(file => { + const filePath = resolvePath(workspacePath, file); + const fileExists = fs.existsSync(filePath); + expect(fileExists).toBe(true); + }); + + await action.handler(mockContext); + + files.forEach(file => { + const filePath = resolvePath(workspacePath, file); + const fileExists = fs.existsSync(filePath); + expect(fileExists).toBe(false); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts new file mode 100644 index 0000000000..068f777ec5 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createTemplateAction } from '../../createTemplateAction'; +import { InputError } from '@backstage/errors'; +import { resolveSafeChildPath } from '@backstage/backend-common'; +import fs from 'fs-extra'; + +export const createFilesystemDeleteAction = () => { + return createTemplateAction<{ files: string[] }>({ + id: 'fs:delete', + description: 'Deletes files and directories from the workspace', + schema: { + input: { + required: ['files'], + type: 'object', + properties: { + files: { + title: 'Files', + description: 'A list of files and directories that will be deleted', + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + async handler(ctx) { + if (!Array.isArray(ctx.input?.files)) { + throw new InputError('files must be an Array'); + } + + for (const file of ctx.input.files) { + const filepath = resolveSafeChildPath(ctx.workspacePath, file); + + try { + await fs.remove(filepath); + ctx.logger.info(`File ${filepath} deleted successfully`); + } catch (err) { + ctx.logger.error(`Failed to delete file ${filepath}:`, err); + throw err; + } + } + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts similarity index 60% rename from plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts rename to plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts index 060b99b3a8..84e477b16b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,5 @@ * limitations under the License. */ -export { AzurePreparer } from './azure'; -export { BitbucketPreparer } from './bitbucket'; -export { FilePreparer } from './file'; -export { GithubPreparer } from './github'; -export { GitlabPreparer } from './gitlab'; -export { Preparers } from './preparers'; -export type { PreparerBase, PreparerBuilder, PreparerOptions } from './types'; +export { createFilesystemDeleteAction } from './delete'; +export { createFilesystemRenameAction } from './rename'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts new file mode 100644 index 0000000000..8452735083 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as os from 'os'; +import mockFs from 'mock-fs'; +import { resolve as resolvePath } from 'path'; +import { createFilesystemRenameAction } from './rename'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +import fs from 'fs-extra'; + +const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const workspacePath = resolvePath(root, 'my-workspace'); + +describe('fs:rename', () => { + const action = createFilesystemRenameAction(); + + const mockInputFiles = [ + { + from: 'unit-test-a.js', + to: 'new-a.js', + }, + { + from: 'unit-test-b.js', + to: 'new-b.js', + }, + { + from: 'a-folder', + to: 'brand-new-folder', + }, + ]; + const mockContext = { + input: { + files: mockInputFiles, + }, + workspacePath, + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + beforeEach(() => { + jest.restoreAllMocks(); + + mockFs({ + [workspacePath]: { + 'unit-test-a.js': 'hello', + 'unit-test-b.js': 'world', + 'unit-test-c.js': 'i will be overwritten :-(', + 'a-folder': { + 'file.md': 'content', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should throw an error when files is not an array', async () => { + await expect( + action.handler({ + ...mockContext, + input: { files: undefined }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: {} }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: '' }, + }), + ).rejects.toThrow(/files must be an Array/); + + await expect( + action.handler({ + ...mockContext, + input: { files: null }, + }), + ).rejects.toThrow(/files must be an Array/); + }); + + it('should throw an error when files have missing from/to', async () => { + await expect( + action.handler({ + ...mockContext, + input: { files: ['old.md'] }, + }), + ).rejects.toThrow(/each file must have a from and to property/); + + await expect( + action.handler({ + ...mockContext, + input: { files: [{ from: 'old.md' }] }, + }), + ).rejects.toThrow(/each file must have a from and to property/); + + await expect( + action.handler({ + ...mockContext, + input: { files: [{ to: 'new.md' }] }, + }), + ).rejects.toThrow(/each file must have a from and to property/); + }); + + it('should throw when file name is not relative to the workspace', async () => { + await expect( + action.handler({ + ...mockContext, + input: { files: [{ from: 'index.js', to: '/core/../../../index.js' }] }, + }), + ).rejects.toThrow( + /Relative path is not allowed to refer to a directory outside its parent/, + ); + + await expect( + action.handler({ + ...mockContext, + input: { files: [{ from: '/core/../../../index.js', to: 'index.js' }] }, + }), + ).rejects.toThrow( + /Relative path is not allowed to refer to a directory outside its parent/, + ); + }); + + it('should throw is trying to override by mistake', async () => { + const destFile = 'unit-test-c.js'; + const filePath = resolvePath(workspacePath, destFile); + const beforeContent = fs.readFileSync(filePath, 'utf-8'); + + await expect( + action.handler({ + ...mockContext, + input: { + files: [ + { + from: 'unit-test-a.js', + to: 'unit-test-c.js', + }, + ], + }, + }), + ).rejects.toThrow(/dest already exists/); + + const afterContent = fs.readFileSync(filePath, 'utf-8'); + + expect(beforeContent).toEqual(afterContent); + }); + + it('should call fs.move with the correct values', async () => { + mockInputFiles.forEach(file => { + const filePath = resolvePath(workspacePath, file.from); + const fileExists = fs.existsSync(filePath); + expect(fileExists).toBe(true); + }); + + await action.handler(mockContext); + + mockInputFiles.forEach(file => { + const filePath = resolvePath(workspacePath, file.from); + const fileExists = fs.existsSync(filePath); + expect(fileExists).toBe(false); + }); + }); + + it('should override when requested', async () => { + const sourceFile = 'unit-test-a.js'; + const destFile = 'unit-test-c.js'; + const sourceFilePath = resolvePath(workspacePath, sourceFile); + const destFilePath = resolvePath(workspacePath, destFile); + + const sourceBeforeContent = fs.readFileSync(sourceFilePath, 'utf-8'); + const destBeforeContent = fs.readFileSync(destFilePath, 'utf-8'); + + expect(sourceBeforeContent).not.toEqual(destBeforeContent); + + await action.handler({ + ...mockContext, + input: { + files: [ + { + from: sourceFile, + to: destFile, + overwrite: true, + }, + ], + }, + }); + + const destAfterContent = fs.readFileSync(destFilePath, 'utf-8'); + + expect(sourceBeforeContent).toEqual(destAfterContent); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts new file mode 100644 index 0000000000..aaadd5df57 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createTemplateAction } from '../../createTemplateAction'; +import { resolveSafeChildPath } from '@backstage/backend-common'; + +import { InputError } from '@backstage/errors'; +import { JsonObject } from '@backstage/config'; +import fs from 'fs-extra'; + +interface FilesToRename extends JsonObject { + from: string; + to: string; +} + +export const createFilesystemRenameAction = () => { + return createTemplateAction<{ files: FilesToRename }>({ + id: 'fs:rename', + description: 'Renames files and directories within the workspace', + schema: { + input: { + required: ['files'], + type: 'object', + properties: { + files: { + title: 'Files', + description: + 'A list of file and directory names that will be renamed', + type: 'array', + items: { + type: 'object', + required: ['from', 'to'], + properties: { + from: { + type: 'string', + title: 'The source location of the file to be renamed', + }, + to: { + type: 'string', + title: 'The destination of the new file', + }, + overwrite: { + type: 'boolean', + title: + 'Overwrite existing file or directory, default is false', + }, + }, + }, + }, + }, + }, + }, + async handler(ctx) { + if (!Array.isArray(ctx.input?.files)) { + throw new InputError('files must be an Array'); + } + + for (const file of ctx.input.files) { + if (!file.from || !file.to) { + throw new InputError('each file must have a from and to property'); + } + + const sourceFilepath = resolveSafeChildPath( + ctx.workspacePath, + file.from, + ); + const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to); + + try { + await fs.move(sourceFilepath, destFilepath, { + overwrite: file.overwrite ?? false, + }); + ctx.logger.info( + `File ${sourceFilepath} renamed to ${destFilepath} successfully`, + ); + } catch (err) { + ctx.logger.error( + `Failed to rename file ${sourceFilepath} to ${destFilepath}:`, + err, + ); + throw err; + } + } + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts similarity index 69% rename from plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index 6eebb4692a..e2459a8ca7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,21 +14,62 @@ * limitations under the License. */ +import { spawn } from 'child_process'; +import { PassThrough, Writable } from 'stream'; import globby from 'globby'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; import { Octokit } from '@octokit/rest'; +export type RunCommandOptions = { + command: string; + args: string[]; + logStream?: Writable; +}; + +export const runCommand = async ({ + command, + args, + logStream = new PassThrough(), +}: RunCommandOptions) => { + await new Promise((resolve, reject) => { + const process = spawn(command, args); + + process.stdout.on('data', stream => { + logStream.write(stream); + }); + + process.stderr.on('data', stream => { + logStream.write(stream); + }); + + process.on('error', error => { + return reject(error); + }); + + process.on('close', code => { + if (code !== 0) { + return reject(`Command ${command} failed, exit code: ${code}`); + } + return resolve(); + }); + }); +}; + export async function initRepoAndPush({ dir, remoteUrl, auth, logger, + defaultBranch = 'master', + gitAuthorInfo, }: { dir: string; remoteUrl: string; auth: { username: string; password: string }; logger: Logger; + defaultBranch?: string; + gitAuthorInfo?: { name?: string; email?: string }; }): Promise { const git = Git.fromAuth({ username: auth.username, @@ -38,6 +79,7 @@ export async function initRepoAndPush({ await git.init({ dir, + defaultBranch, }); const paths = await globby(['./**', './**/.*', '!.git'], { @@ -50,11 +92,17 @@ export async function initRepoAndPush({ await git.add({ dir, filepath }); } + // use provided info if possible, otherwise use fallbacks + const authorInfo = { + name: gitAuthorInfo?.name ?? 'Scaffolder', + email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io', + }; + await git.commit({ dir, message: 'Initial commit', - author: { name: 'Scaffolder', email: 'scaffolder@backstage.io' }, - committer: { name: 'Scaffolder', email: 'scaffolder@backstage.io' }, + author: authorInfo, + committer: authorInfo, }); await git.addRemote({ @@ -74,6 +122,7 @@ type BranchProtectionOptions = { owner: string; repoName: string; logger: Logger; + defaultBranch?: string; }; export const enableBranchProtectionOnDefaultRepoBranch = async ({ @@ -81,6 +130,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({ client, owner, logger, + defaultBranch = 'master', }: BranchProtectionOptions): Promise => { const tryOnce = async () => { try { @@ -97,7 +147,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({ }, owner, repo: repoName, - branch: 'master', + branch: defaultBranch, required_status_checks: { strict: true, contexts: [] }, restrictions: null, enforce_admins: true, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts index 047bcb421d..849b056ac9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts @@ -18,4 +18,6 @@ export * from './catalog'; export { createBuiltinActions } from './createBuiltinActions'; export * from './debug'; export * from './fetch'; +export * from './filesystem'; export * from './publish'; +export { runCommand } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index 2f468cda25..30995dee38 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,32 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('../../../stages/publish/helpers'); jest.mock('azure-devops-node-api', () => ({ WebApi: jest.fn(), getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), })); +jest.mock('../helpers'); + import { createPublishAzureAction } from './azure'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { WebApi } from 'azure-devops-node-api'; import { PassThrough } from 'stream'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; describe('publish:azure', () => { - const integrations = ScmIntegrations.fromConfig( - new ConfigReader({ - integrations: { - azure: [ - { host: 'dev.azure.com', token: 'tokenlols' }, - { host: 'myazurehostnotoken.com' }, - ], - }, - }), - ); - const action = createPublishAzureAction({ integrations }); + const config = new ConfigReader({ + integrations: { + azure: [ + { host: 'dev.azure.com', token: 'tokenlols' }, + { host: 'myazurehostnotoken.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishAzureAction({ integrations, config }); const mockContext = { input: { repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org', @@ -162,8 +163,73 @@ describe('publish:azure', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + defaultBranch: 'master', auth: { username: 'notempty', password: 'tokenlols' }, logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the correct default branch', async () => { + mockGitClient.createRepository.mockImplementation(() => ({ + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + })); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + defaultBranch: 'master', + auth: { username: 'notempty', password: 'tokenlols' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + azure: [ + { host: 'dev.azure.com', token: 'tokenlols' }, + { host: 'myazurehostnotoken.com' }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = ScmIntegrations.fromConfig( + customAuthorConfig, + ); + const customAuthorAction = createPublishAzureAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + mockGitClient.createRepository.mockImplementation(() => ({ + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + })); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + auth: { username: 'notempty', password: 'tokenlols' }, + logger: mockContext.logger, + defaultBranch: 'master', + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 21b2537614..c58dd69424 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -16,20 +16,23 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; +import { Config } from '@backstage/config'; export function createPublishAzureAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; return createTemplateAction<{ repoUrl: string; description?: string; + defaultBranch?: string; sourcePath?: string; }>({ id: 'publish:azure', @@ -48,6 +51,11 @@ export function createPublishAzureAction(options: { title: 'Repository Description', type: 'string', }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, sourcePath: { title: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', @@ -70,9 +78,9 @@ export function createPublishAzureAction(options: { }, }, async handler(ctx) { - const { owner, repo, host, organization } = parseRepoUrl( - ctx.input.repoUrl, - ); + const { repoUrl, defaultBranch = 'master' } = ctx.input; + + const { owner, repo, host, organization } = parseRepoUrl(repoUrl); if (!organization) { throw new InputError( @@ -117,14 +125,21 @@ export function createPublishAzureAction(options: { // so it's just the base path I think const repoContentsUrl = remoteUrl; + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, + defaultBranch, auth: { username: 'notempty', password: integrationConfig.config.token, }, logger: ctx.logger, + gitAuthorInfo, }); ctx.output('remoteUrl', remoteUrl); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts index 00ada3a4bc..9fe245935b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('../../../stages/publish/helpers'); + +jest.mock('../helpers'); import { createPublishBitbucketAction } from './bitbucket'; import { rest } from 'msw'; @@ -23,30 +24,30 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { PassThrough } from 'stream'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; describe('publish:bitbucket', () => { - const integrations = ScmIntegrations.fromConfig( - new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'bitbucket.org', - token: 'tokenlols', - }, - { - host: 'hosted.bitbucket.com', - token: 'thing', - apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', - }, - { - host: 'notoken.bitbucket.com', - }, - ], - }, - }), - ); - const action = createPublishBitbucketAction({ integrations }); + const config = new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'bitbucket.org', + token: 'tokenlols', + }, + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'notoken.bitbucket.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketAction({ integrations, config }); const mockContext = { input: { repoUrl: 'bitbucket.org?repo=repo&owner=owner', @@ -286,8 +287,123 @@ describe('publish:bitbucket', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, remoteUrl: 'https://bitbucket.org/owner/cloneurl', + defaultBranch: 'master', auth: { username: 'x-token-auth', password: 'tokenlols' }, logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the correct default branch', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/owner/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/owner/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/owner/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/owner/cloneurl', + defaultBranch: 'main', + auth: { username: 'x-token-auth', password: 'tokenlols' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'bitbucket.org', + token: 'tokenlols', + }, + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'notoken.bitbucket.com', + }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = ScmIntegrations.fromConfig( + customAuthorConfig, + ); + const customAuthorAction = createPublishBitbucketAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/owner/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/owner/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/owner/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/owner/cloneurl', + auth: { username: 'x-token-auth', password: 'tokenlols' }, + logger: mockContext.logger, + defaultBranch: 'master', + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index ce937108c4..ffd2e4d6a7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -20,9 +20,10 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; import { createTemplateAction } from '../../createTemplateAction'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; +import { Config } from '@backstage/config'; const createBitbucketCloudRepository = async (opts: { owner: string; @@ -184,12 +185,14 @@ const performEnableLFS = async (opts: { export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; return createTemplateAction<{ repoUrl: string; description: string; + defaultBranch?: string; repoVisibility: 'private' | 'public'; sourcePath?: string; enableLFS: boolean; @@ -215,6 +218,11 @@ export function createPublishBitbucketAction(options: { type: 'string', enum: ['private', 'public'], }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, sourcePath: { title: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', @@ -245,6 +253,7 @@ export function createPublishBitbucketAction(options: { const { repoUrl, description, + defaultBranch = 'master', repoVisibility = 'private', enableLFS = false, } = ctx.input; @@ -277,6 +286,11 @@ export function createPublishBitbucketAction(options: { apiBaseUrl, }); + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, @@ -288,7 +302,9 @@ export function createPublishBitbucketAction(options: { ? integrationConfig.config.appPassword : integrationConfig.config.token ?? '', }, + defaultBranch, logger: ctx.logger, + gitAuthorInfo, }); if (enableLFS && host !== 'bitbucket.org') { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 716fa704e9..f0725d6e8e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('../../../stages/publish/helpers'); + +jest.mock('../helpers'); jest.mock('@octokit/rest'); import { createPublishGithubAction } from './github'; @@ -21,21 +22,21 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { PassThrough } from 'stream'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; import { when } from 'jest-when'; describe('publish:github', () => { - const integrations = ScmIntegrations.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { host: 'github.com', token: 'tokenlols' }, - { host: 'ghe.github.com' }, - ], - }, - }), - ); - const action = createPublishGithubAction({ integrations }); + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishGithubAction({ integrations, config }); const mockContext = { input: { repoUrl: 'github.com?repo=repo&owner=owner', @@ -175,8 +176,87 @@ describe('publish:github', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, remoteUrl: 'https://github.com/clone/url.git', + defaultBranch: 'master', auth: { username: 'x-access-token', password: 'tokenlols' }, logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the correct defaultBranch main', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://github.com/clone/url.git', + defaultBranch: 'main', + auth: { username: 'x-access-token', password: 'tokenlols' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = ScmIntegrations.fromConfig( + customAuthorConfig, + ); + const customAuthorAction = createPublishGithubAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://github.com/clone/url.git', + defaultBranch: 'master', + auth: { username: 'x-access-token', password: 'tokenlols' }, + logger: mockContext.logger, + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, }); }); @@ -430,4 +510,34 @@ describe('publish:github', () => { 'https://github.com/html/url/blob/master', ); }); + + it('should use main as default branch', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/clone/url.git', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://github.com/html/url/blob/main', + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 48d59024eb..05301bda85 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -22,17 +22,19 @@ import { Octokit } from '@octokit/rest'; import { enableBranchProtectionOnDefaultRepoBranch, initRepoAndPush, -} from '../../../stages/publish/helpers'; +} from '../helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; +import { Config } from '@backstage/config'; type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage'; type Collaborator = { access: Permission; username: string }; export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; const credentialsProviders = new Map( integrations.github.list().map(integration => { @@ -45,6 +47,7 @@ export function createPublishGithubAction(options: { repoUrl: string; description?: string; access?: string; + defaultBranch?: string; sourcePath?: string; repoVisibility: 'private' | 'internal' | 'public'; collaborators: Collaborator[]; @@ -77,6 +80,11 @@ export function createPublishGithubAction(options: { type: 'string', enum: ['private', 'public', 'internal'], }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, sourcePath: { title: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', @@ -131,6 +139,7 @@ export function createPublishGithubAction(options: { description, access, repoVisibility = 'private', + defaultBranch = 'master', collaborators, topics, } = ctx.input; @@ -239,16 +248,23 @@ export function createPublishGithubAction(options: { } const remoteUrl = newRepo.clone_url; - const repoContentsUrl = `${newRepo.html_url}/blob/master`; + const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`; + + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, + defaultBranch, auth: { username: 'x-access-token', password: token, }, logger: ctx.logger, + gitAuthorInfo, }); try { @@ -257,10 +273,11 @@ export function createPublishGithubAction(options: { client, repoName: newRepo.name, logger: ctx.logger, + defaultBranch, }); } catch (e) { - throw new Error( - `Failed to add branch protection to '${newRepo.name}', ${e}`, + ctx.logger.warn( + `Skipping: default branch protection on '${newRepo.name}', ${e.message}`, ); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitab.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts similarity index 66% rename from plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitab.test.ts rename to plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts index 79de6fd669..5150205a08 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('../../../stages/publish/helpers'); +jest.mock('../helpers'); jest.mock('@gitbeaker/node'); import { createPublishGitlabAction } from './gitlab'; @@ -21,27 +21,27 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { PassThrough } from 'stream'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; describe('publish:gitlab', () => { - const integrations = ScmIntegrations.fromConfig( - new ConfigReader({ - integrations: { - gitlab: [ - { - host: 'gitlab.com', - token: 'tokenlols', - apiBaseUrl: 'https://api.gitlab.com', - }, - { - host: 'hosted.gitlab.com', - apiBaseUrl: 'https://api.hosted.gitlab.com', - }, - ], - }, - }), - ); - const action = createPublishGitlabAction({ integrations }); + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishGitlabAction({ integrations, config }); const mockContext = { input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', @@ -139,9 +139,83 @@ describe('publish:gitlab', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, + defaultBranch: 'master', remoteUrl: 'http://mockurl.git', auth: { username: 'oauth2', password: 'tokenlols' }, logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the correct default branch', async () => { + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'http://mockurl.git', + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + defaultBranch: 'main', + remoteUrl: 'http://mockurl.git', + auth: { username: 'oauth2', password: 'tokenlols' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initRepoAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = ScmIntegrations.fromConfig( + customAuthorConfig, + ); + const customAuthorAction = createPublishGitlabAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'http://mockurl.git', + }); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'http://mockurl.git', + auth: { username: 'oauth2', password: 'tokenlols' }, + logger: mockContext.logger, + defaultBranch: 'master', + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 3f24b98e6c..b99d40b1f7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -17,17 +17,20 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { Gitlab } from '@gitbeaker/node'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { initRepoAndPush } from '../helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; +import { Config } from '@backstage/config'; export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; return createTemplateAction<{ repoUrl: string; + defaultBranch?: string; repoVisibility: 'private' | 'internal' | 'public'; sourcePath?: string; }>({ @@ -48,6 +51,11 @@ export function createPublishGitlabAction(options: { type: 'string', enum: ['private', 'public', 'internal'], }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, sourcePath: { title: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', @@ -70,7 +78,11 @@ export function createPublishGitlabAction(options: { }, }, async handler(ctx) { - const { repoUrl, repoVisibility = 'private' } = ctx.input; + const { + repoUrl, + repoVisibility = 'private', + defaultBranch = 'master', + } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl); @@ -111,14 +123,21 @@ export function createPublishGitlabAction(options: { const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, ''); const repoContentsUrl = `${remoteUrl}/-/blob/master`; + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl: http_url_to_repo as string, + defaultBranch, auth: { username: 'oauth2', password: integrationConfig.config.token, }, logger: ctx.logger, + gitAuthorInfo, }); ctx.output('remoteUrl', remoteUrl); diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 038873b42c..284e372b4c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -13,7 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { createLegacyActions } from './stages/legacy'; -export * from './stages'; -export * from './jobs'; export * from './actions'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts deleted file mode 100644 index e5d298163b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { makeLogStream } from './logger'; - -describe('Logger', () => { - const mockMeta = { test: 'blob' }; - - it('should return empty log lines by default', async () => { - const { log } = makeLogStream(mockMeta); - - expect(log).toEqual([]); - }); - it('should add lines to the log when using the logger that is returned', async () => { - const { logger, log } = makeLogStream(mockMeta); - - logger.info('TEST LINE'); - logger.warn('WARN LINE'); - - const [first, second] = log; - expect(log.length).toBe(2); - - expect(first).toContain('info'); - expect(first).toContain('TEST LINE'); - - expect(second).toContain('warn'); - expect(second).toContain('WARN LINE'); - }); - - it('should add lines from writing to the stream that is returned', async () => { - const { stream, log } = makeLogStream(mockMeta); - const textLine = 'SOMETHING'; - stream.write(textLine); - - expect(log).toContain(textLine); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts deleted file mode 100644 index 9ffdb33f37..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { PassThrough } from 'stream'; -import * as winston from 'winston'; -import { JsonValue } from '@backstage/config'; - -export const makeLogStream = (meta: Record) => { - const log: string[] = []; - - // Create an empty stream to collect all the log lines into - // one variable for the API. - const stream = new PassThrough(); - stream.on('data', chunk => { - const textValue = chunk.toString().trim(); - if (textValue?.length > 1) log.push(textValue); - }); - - const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.simple(), - ), - defaultMeta: meta, - }); - - logger.add(new winston.transports.Stream({ stream })); - - return { - log, - stream, - logger, - }; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts deleted file mode 100644 index 2bb2171899..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import parseGitUrl from 'git-url-parse'; -import mockFs from 'mock-fs'; -import os from 'os'; -import { RequiredTemplateValues } from '../stages/templater'; -import { makeLogStream } from './logger'; -import { JobProcessor } from './processor'; -import { StageInput } from './types'; - -describe('JobProcessor', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'example@email.com', - }, - }; - - const mockValues: RequiredTemplateValues = { - owner: 'blobby', - storePath: 'https://github.com/backstage/mock-repo', - destination: { - git: parseGitUrl('https://github.com/backstage/mock-repo'), - }, - }; - - const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - - // NOTE(freben): Without this line, mock-fs makes winston/logform break. - // There are a number of reported issues with logform and its use of dynamic - // strings for imports. It confuses webpack. The basic fix is to trigger - // those imports before mock-fs runs. I wanted to add a mock dir - // 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing - // in mock-fs 4. - // Probable REAL fix: https://github.com/winstonjs/logform/pull/117 - makeLogStream({}); - - beforeEach(() => { - mockFs({ - [workingDirectory]: mockFs.directory(), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - - describe('create', () => { - it('creates should create a new job with a unique id', async () => { - const processor = new JobProcessor(workingDirectory); - - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages: [], - }); - - expect(job.id).toMatch( - /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, - ); - }); - - it('should setup the correct context for the job', async () => { - const processor = new JobProcessor(workingDirectory); - - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages: [], - }); - - expect(job.context.entity).toBe(mockEntity); - expect(job.context.values).toBe(mockValues); - }); - - it('should set the status as pending', async () => { - const processor = new JobProcessor(workingDirectory); - - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages: [], - }); - - expect(job.status).toBe('PENDING'); - }); - - it('should create the correct stages', async () => { - const stages: StageInput[] = [ - { - name: 'Do something cool step 1', - handler: jest.fn(), - }, - { - name: 'Do something cool step 2', - handler: jest.fn(), - }, - ]; - - const processor = new JobProcessor(workingDirectory); - - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages, - }); - - expect(job.stages).toHaveLength(stages.length); - - for (let i = 0; i < job.stages.length; i++) { - expect(job.stages[i].name).toBe(stages[i].name); - expect(job.stages[i].status).toBe('PENDING'); - } - }); - }); - - describe('get', () => { - it('return undefined for when the job does not exist', () => { - const processor = new JobProcessor(workingDirectory); - expect(processor.get('123')).not.toBeDefined(); - }); - - it('should return the exact same instance of the job when one is created', async () => { - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages: [], - }); - - expect(processor.get(job.id)).toBe(job); - }); - }); - - describe('process', () => { - it('throws an error when the status of the job is not in pending state', async () => { - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages: [], - }); - - job.status = 'STARTED'; - - await expect(processor.run(job)).rejects.toThrow( - /Job is not in a 'PENDING' state/, - ); - }); - - it('will call each of the handlers in the stages', async () => { - const stages: StageInput[] = [ - { - name: 'c/o', - handler: jest.fn(), - }, - { - name: 'g/p', - handler: jest.fn(), - }, - ]; - - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages, - }); - - await processor.run(job); - - for (const stage of stages) { - expect(stage.handler).toHaveBeenCalled(); - } - }); - - it('should set all stages to complete and the job to complete when finishes without errors', async () => { - const stages: StageInput[] = [ - { - name: 'c/o', - handler: jest.fn(), - }, - { - name: 'g/p', - handler: jest.fn(), - }, - ]; - - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages, - }); - - await processor.run(job); - - for (const stage of job.stages) { - expect(stage.status).toBe('COMPLETED'); - } - - expect(job.status).toBe('COMPLETED'); - }); - - it('should merge the return value from previous steps into the context of the next step', async () => { - const stages: StageInput[] = [ - { - name: 'c/o', - handler: jest - .fn() - .mockResolvedValue({ first: 'ben', second: 'lambert' }), - }, - { - name: 'g/p', - handler: jest - .fn() - .mockResolvedValue({ second: 'linus', third: 'lambert' }), - }, - { - name: 'go', - handler: jest.fn(), - }, - ]; - - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages, - }); - - await processor.run(job); - - expect(stages[1].handler).toHaveBeenCalledWith( - expect.objectContaining({ first: 'ben', second: 'lambert' }), - ); - - expect(stages[2].handler).toHaveBeenCalledWith( - expect.objectContaining({ - first: 'ben', - second: 'linus', - third: 'lambert', - }), - ); - }); - - it('should fail the job and the step if one of them fails', async () => { - const fail = new Error('something went wrong here'); - const stages: StageInput[] = [ - { - name: 'c/o', - handler: jest.fn(), - }, - { - name: 'g/p', - handler: jest.fn().mockRejectedValue(fail), - }, - { - name: 'go', - handler: jest.fn(), - }, - ]; - - const processor = new JobProcessor(workingDirectory); - const job = processor.create({ - entity: mockEntity, - values: mockValues, - stages, - }); - - await processor.run(job); - - expect(job.status).toBe('FAILED'); - expect(job.stages[0].status).toBe('COMPLETED'); - expect(job.stages[1].status).toBe('FAILED'); - expect(job.stages[2].status).toBe('PENDING'); - expect(job.error?.message).toBe('something went wrong here'); - expect(job.stages[1].log.join()).toContain('something went wrong here'); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts deleted file mode 100644 index ef603477c1..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import os from 'os'; -import fs from 'fs-extra'; -import { Processor, Job, StageContext, StageInput } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import * as uuid from 'uuid'; -import path from 'path'; -import { TemplaterValues } from '../stages/templater'; -import { makeLogStream } from './logger'; -import { Logger } from 'winston'; -import { Config } from '@backstage/config'; - -export type JobAndDirectoryTuple = { - job: Job; - directory: string; -}; - -export class JobProcessor implements Processor { - private readonly workingDirectory: string; - private readonly jobs: Map; - - static async fromConfig({ - config, - logger, - }: { - config: Config; - logger: Logger; - }) { - let workingDirectory: string; - if (config.has('backend.workingDirectory')) { - workingDirectory = config.getString('backend.workingDirectory'); - try { - // Check if working directory exists and is writable - await fs.promises.access( - workingDirectory, - fs.constants.F_OK | fs.constants.W_OK, - ); - logger.info(`using working directory: ${workingDirectory}`); - } catch (err) { - logger.error( - `working directory ${workingDirectory} ${ - err.code === 'ENOENT' ? 'does not exist' : 'is not writable' - }`, - ); - throw err; - } - } else { - workingDirectory = os.tmpdir(); - } - - return new JobProcessor(workingDirectory); - } - - constructor(workingDirectory: string) { - this.workingDirectory = workingDirectory; - this.jobs = new Map(); - } - - create({ - entity, - values, - stages, - }: { - entity: TemplateEntityV1alpha1; - values: TemplaterValues; - stages: StageInput[]; - }): Job { - const id = uuid.v4(); - const { logger, stream } = makeLogStream({ id }); - - const context: StageContext = { - entity, - values, - logger, - logStream: stream, - workspacePath: path.join(this.workingDirectory, id), - }; - - const job: Job = { - id, - context, - stages: stages.map(stage => ({ - handler: stage.handler, - log: [], - name: stage.name, - status: 'PENDING', - })), - status: 'PENDING', - }; - - this.jobs.set(job.id, job); - - return job; - } - - get(id: string): Job | undefined { - return this.jobs.get(id); - } - - async run(job: Job): Promise { - if (job.status !== 'PENDING') { - throw new Error("Job is not in a 'PENDING' state"); - } - - await fs.mkdir(job.context.workspacePath); - - job.status = 'STARTED'; - - try { - for (const stage of job.stages) { - // Create a logger for each stage so we can create separate - // Streams for each step. - const { logger, log, stream } = makeLogStream({ - id: job.id, - stage: stage.name, - }); - // Attach the logger to the stage, and setup some timestamps. - stage.log = log; - stage.startedAt = Date.now(); - - try { - // Run the handler with the context created for the Job and some - // Additional logging helpers. - stage.status = 'STARTED'; - const handlerResponse = await stage.handler({ - ...job.context, - logger, - logStream: stream, - }); - - // If the handler returns something, then let's merge this onto the - // context for the next stage to use as it might be relevant. - if (handlerResponse) { - job.context = { - ...job.context, - ...handlerResponse, - }; - } - - // Complete the current stage - stage.status = 'COMPLETED'; - } catch (error) { - // Log to the current stage the error that occurred and fail the stage. - stage.status = 'FAILED'; - logger.error(`Stage failed with error: ${error.message}`); - logger.debug(error.stack); - // Throw the error so the job can be failed too. - throw error; - } finally { - // Always set the stage end timestamp. - stage.endedAt = Date.now(); - } - } - - // If all went to plan, complete the job. - job.status = 'COMPLETED'; - } catch (error) { - // If something went wrong, fail the job, and set the error property on the job. - job.error = { name: error.name, message: error.message }; - job.status = 'FAILED'; - } finally { - await fs.remove(job.context.workspacePath); - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts deleted file mode 100644 index 84509a6c0f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Writable } from 'stream'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { TemplaterValues } from '../stages/templater'; -import { Logger } from 'winston'; - -// Context will be a mutable object which is passed between stages -// To share data, but also thinking that we can pass in functions here too -// To maybe create sub steps or fail the entire thing, or skip stages down the line. -export type StageContext = { - values: TemplaterValues; - entity: TemplateEntityV1alpha1; - logger: Logger; - logStream: Writable; - workspacePath: string; -} & T; - -export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; - -export interface StageResult extends StageInput { - log: string[]; - status: ProcessorStatus; - startedAt?: number; - endedAt?: number; -} - -export interface StageInput { - name: string; - handler(ctx: StageContext): Promise; -} - -export type Job = { - id: string; - context: StageContext; - status: ProcessorStatus; - stages: StageResult[]; - error?: Error; -}; - -export type Processor = { - create({ - entity, - values, - stages, - }: { - entity: TemplateEntityV1alpha1; - values: TemplaterValues; - stages: StageInput[]; - }): Job; - - get(id: string): Job | undefined; - - run(job: Job): Promise; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts deleted file mode 100644 index 8f21485f64..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - LOCATION_ANNOTATION, - TemplateEntityV1alpha1, -} from '@backstage/catalog-model'; -import { joinGitUrlPath, parseLocationAnnotation } from './helpers'; - -describe('Helpers', () => { - describe('parseLocationAnnotation', () => { - it('throws an exception when no annotation location', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-d@example.com', - }, - }; - - expect(() => parseLocationAnnotation(mockEntity)).toThrow( - expect.objectContaining({ - name: 'InputError', - message: `No location annotation provided in entity: ${mockEntity.metadata.name}`, - }), - ); - }); - - it('should throw an error when the protocol part is not set in the location annotation', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - ':https://github.com/o/r/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-b@example.com', - }, - }; - - expect(() => parseLocationAnnotation(mockEntity)).toThrow( - expect.objectContaining({ - name: 'TypeError', - message: - "Unable to parse location reference ':https://github.com/o/r/blob/master/template.yaml', expected ':', e.g. 'url:https://host/path'", - }), - ); - }); - - it('should throw an error when the location part is not set in the location annotation', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: 'github:', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-a@example.com', - }, - }; - - expect(() => parseLocationAnnotation(mockEntity)).toThrow( - expect.objectContaining({ - name: 'TypeError', - message: `Unable to parse location reference 'github:', expected ':', e.g. 'url:https://host/path'`, - }), - ); - }); - - it('should parse the location and protocol correctly for simple locations', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: 'file:./path', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-b@example.com', - }, - }; - - expect(parseLocationAnnotation(mockEntity)).toEqual({ - protocol: 'file', - location: './path', - }); - }); - - it('should parse the location and protocol correctly for complex with unescaped locations', () => { - const mockEntity: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: 'github:https://lol.com/:something/shello', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - owner: 'team-c@example.com', - }, - }; - - expect(parseLocationAnnotation(mockEntity)).toEqual({ - protocol: 'github', - location: 'https://lol.com/:something/shello', - }); - }); - }); - - describe('joinGitUrlPath', () => { - it.each([ - [ - 'https://github.com/o/r/blob/master/template.yaml', - 'template', - 'https://github.com/o/r/blob/master/template', - ], - [ - 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', - undefined, - 'https://dev.azure.com/o/p/_git/template-repo?path=%2F', - ], - [ - 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', - 'a', - 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa', - ], - [ - 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml', - 'b', - 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb', - ], - [ - 'https://github.com/o/r/blob/master/template.yaml', - undefined, - 'https://github.com/o/r/blob/master', - ], - [ - 'https://github.com/o/r/blob/master/template.yaml', - 'template', - 'https://github.com/o/r/blob/master/template', - ], - [ - 'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml', - 'template', - 'https://github.com/o/r/blob/master/templates/graphql-starter/template', - ], - [ - 'https://gitlab.com/o/r/-/blob/master/template.yaml', - undefined, - 'https://gitlab.com/o/r/-/blob/master', - ], - [ - 'https://gitlab.com/o/r/-/blob/master/template.yaml', - 'template', - 'https://gitlab.com/o/r/-/blob/master/template', - ], - [ - 'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml', - '../../c', - 'https://gitlab.com/o/r/-/blob/master/a/c', - ], - [ - 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', - undefined, - 'https://bitbucket.org/p/r/src/master/a/b', - ], - [ - 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', - 'c', - 'https://bitbucket.org/p/r/src/master/a/b/c', - ], - [ - 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', - '../c', - 'https://bitbucket.org/p/r/src/master/a/c', - ], - ])('should join git url %s with path %s', (url, path, result) => { - expect(joinGitUrlPath(url, path)).toBe(result); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts deleted file mode 100644 index 8085277030..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import { - LOCATION_ANNOTATION, - parseLocationReference, - TemplateEntityV1alpha1, -} from '@backstage/catalog-model'; -import { posix as posixPath } from 'path'; - -export type ParsedLocationAnnotation = { - protocol: 'file' | 'url'; - location: string; -}; - -export const parseLocationAnnotation = ( - entity: TemplateEntityV1alpha1, -): ParsedLocationAnnotation => { - const annotation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - if (!annotation) { - throw new InputError( - `No location annotation provided in entity: ${entity.metadata.name}`, - ); - } - - const { type, target } = parseLocationReference(annotation); - return { - protocol: type as 'file' | 'url', - location: target, - }; -}; - -export function joinGitUrlPath(repoUrl: string, path?: string): string { - const parsed = new URL(repoUrl); - - if (parsed.hostname.endsWith('azure.com')) { - const templatePath = posixPath.normalize( - posixPath.join( - posixPath.dirname(parsed.searchParams.get('path') || '/'), - path || '.', - ), - ); - parsed.searchParams.set('path', templatePath); - return parsed.toString(); - } - - return new URL(path || '.', repoUrl).toString().replace(/\/$/, ''); -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts deleted file mode 100644 index efbb241470..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createTemplateAction } from '../actions'; -import { FilePreparer, PreparerBuilder } from './prepare'; -import { PublisherBuilder } from './publish'; -import { TemplaterBuilder, TemplaterValues } from './templater'; - -type Options = { - preparers: PreparerBuilder; - templaters: TemplaterBuilder; - publishers: PublisherBuilder; -}; - -export function createLegacyActions(options: Options) { - const { preparers, templaters, publishers } = options; - - return [ - createTemplateAction({ - id: 'legacy:prepare', - async handler(ctx) { - ctx.logger.info('Preparing the skeleton'); - const { protocol, url } = ctx.input; - const preparer = - protocol === 'file' - ? new FilePreparer() - : preparers.get(url as string); - - await preparer.prepare({ - url: url as string, - logger: ctx.logger, - workspacePath: ctx.workspacePath, - }); - }, - }), - createTemplateAction({ - id: 'legacy:template', - async handler(ctx) { - ctx.logger.info('Running the templater'); - const templater = templaters.get(ctx.input.templater as string); - await templater.run({ - workspacePath: ctx.workspacePath, - logStream: ctx.logStream, - values: ctx.input.values as TemplaterValues, - }); - }, - }), - createTemplateAction({ - id: 'legacy:publish', - async handler(ctx) { - const { values } = ctx.input; - if ( - typeof values !== 'object' || - values === null || - Array.isArray(values) - ) { - throw new Error( - `Invalid values passed to publish, got ${typeof values}`, - ); - } - const storePath = values.storePath as unknown; - if (typeof storePath !== 'string') { - throw new Error( - `Invalid store path passed to publish, got ${typeof storePath}`, - ); - } - const owner = values.owner as unknown; - if (typeof owner !== 'string') { - throw new Error( - `Invalid owner passed to publish, got ${typeof owner}`, - ); - } - - const publisher = publishers.get(storePath); - ctx.logger.info('Will now store the template'); - const { remoteUrl, catalogInfoUrl } = await publisher.publish({ - values: { - ...values, - owner, - storePath, - }, - workspacePath: ctx.workspacePath, - logger: ctx.logger, - }); - ctx.output('remoteUrl', remoteUrl); - if (catalogInfoUrl) { - ctx.output('catalogInfoUrl', catalogInfoUrl); - } - }, - }), - ]; -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts deleted file mode 100644 index 74a21e0833..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -import { AzurePreparer } from './azure'; -import { getVoidLogger, Git } from '@backstage/backend-common'; - -jest.mock('fs-extra'); - -describe('AzurePreparer', () => { - const mockGitClient = { - clone: jest.fn(), - }; - - const logger = getVoidLogger(); - - jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - - const preparer = AzurePreparer.fromConfig({ - host: 'dev.azure.com', - token: 'fake-azure-token', - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = path.resolve(workspacePath, 'checkout'); - const templatePath = path.resolve(workspacePath, 'template'); - const prepareOptions = { - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - workspacePath, - logger, - }; - - it('calls the clone command with token from integrations config', async () => { - await preparer.prepare(prepareOptions); - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - password: 'fake-azure-token', - username: 'notempty', - }); - expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); - }); - - it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(prepareOptions); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: checkoutPath, - }); - }); - - it('calls the clone command with the correct arguments for a repository with a specified branch', async () => { - await preparer.prepare({ - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster', - logger, - workspacePath, - }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: checkoutPath, - ref: 'master', - }); - }); - - it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - await preparer.prepare({ - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - workspacePath, - logger, - }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: - 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: checkoutPath, - }); - expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - }); - - it('moves the template from path if it is specified', async () => { - await preparer.prepare({ - url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( - './subdir', - )}`, - logger, - workspacePath, - }); - - expect(fs.move).toHaveBeenCalledWith( - path.resolve(checkoutPath, 'subdir'), - templatePath, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts deleted file mode 100644 index a405288f9f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import path from 'path'; -import { Git } from '@backstage/backend-common'; -import { PreparerBase, PreparerOptions } from './types'; -import parseGitUrl from 'git-url-parse'; -import { AzureIntegrationConfig } from '@backstage/integration'; - -export class AzurePreparer implements PreparerBase { - static fromConfig(config: AzureIntegrationConfig) { - return new AzurePreparer({ token: config.token }); - } - - constructor(private readonly config: { token?: string }) {} - - async prepare({ url, workspacePath, logger }: PreparerOptions) { - const parsedGitUrl = parseGitUrl(url); - const checkoutPath = path.join(workspacePath, 'checkout'); - const targetPath = path.join(workspacePath, 'template'); - const fullPathToTemplate = path.resolve( - checkoutPath, - parsedGitUrl.filepath ?? '', - ); - - // Username can be anything but the empty string according to: - // https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - const git = this.config.token - ? Git.fromAuth({ - password: this.config.token, - username: 'notempty', - logger, - }) - : Git.fromAuth({ logger }); - - await git.clone({ - url: parsedGitUrl.toString('https'), - ref: parsedGitUrl.ref, - dir: checkoutPath, - }); - - await fs.move(fullPathToTemplate, targetPath); - - try { - await fs.rmdir(path.join(targetPath, '.git')); - } catch { - // Ignore intentionally - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts deleted file mode 100644 index 2c4e24fed0..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs-extra'; -import { BitbucketPreparer } from './bitbucket'; -import { getVoidLogger, Git } from '@backstage/backend-common'; -import path from 'path'; -import os from 'os'; - -jest.mock('fs-extra'); - -describe('BitbucketPreparer', () => { - const logger = getVoidLogger(); - const mockGitClient = { - clone: jest.fn(), - }; - - jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const preparer = BitbucketPreparer.fromConfig({ - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-password', - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = path.resolve(workspacePath, 'checkout'); - const templatePath = path.resolve(workspacePath, 'template'); - - const prepareOptions = { - url: 'https://bitbucket.org/backstage-project/backstage-repo', - logger, - workspacePath, - }; - - it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(prepareOptions); - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: checkoutPath, - ref: expect.any(String), - }); - expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); - }); - - it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { - const preparerCheck = BitbucketPreparer.fromConfig({ - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-password', - }); - await preparerCheck.prepare(prepareOptions); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'fake-user', - password: 'fake-password', - }); - }); - - it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - await preparer.prepare(prepareOptions); - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: checkoutPath, - ref: expect.any(String), - }); - }); - - it('moves a template subdirectory to checkout if specified', async () => { - await preparer.prepare({ - url: 'https://bitbucket.org/foo/bar/src/master/1/2/3', - logger, - workspacePath, - }); - expect(fs.move).toHaveBeenCalledWith( - path.resolve(checkoutPath, '1', '2', '3'), - templatePath, - ); - }); - - it('calls the clone command with with token for auth method', async () => { - const preparerCheck = BitbucketPreparer.fromConfig({ - host: 'bitbucket.org', - token: 'fake-token', - }); - await preparerCheck.prepare(prepareOptions); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'x-token-auth', - password: 'fake-token', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts deleted file mode 100644 index 36f6b07750..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import path from 'path'; -import { Git } from '@backstage/backend-common'; -import { PreparerBase, PreparerOptions } from './types'; -import { BitbucketIntegrationConfig } from '@backstage/integration'; -import parseGitUrl from 'git-url-parse'; - -export class BitbucketPreparer implements PreparerBase { - static fromConfig(config: BitbucketIntegrationConfig) { - return new BitbucketPreparer({ - username: config.username, - token: config.token, - appPassword: config.appPassword, - }); - } - - constructor( - private readonly config: { - username?: string; - token?: string; - appPassword?: string; - }, - ) {} - - async prepare({ url, workspacePath, logger }: PreparerOptions) { - const parsedGitUrl = parseGitUrl(url); - const checkoutPath = path.join(workspacePath, 'checkout'); - const targetPath = path.join(workspacePath, 'template'); - const fullPathToTemplate = path.resolve( - checkoutPath, - parsedGitUrl.filepath ?? '', - ); - - const git = Git.fromAuth({ logger, ...this.getAuth() }); - - await git.clone({ - url: parsedGitUrl.toString('https'), - dir: checkoutPath, - ref: parsedGitUrl.ref, - }); - - await fs.move(fullPathToTemplate, targetPath); - - try { - await fs.rmdir(path.join(targetPath, '.git')); - } catch { - // Ignore intentionally - } - } - - private getAuth(): { username: string; password: string } | undefined { - const { username, token, appPassword } = this.config; - - if (username && appPassword) { - return { username: username, password: appPassword }; - } - - if (token) { - return { - username: 'x-token-auth', - password: token! || appPassword!, - }; - } - - return undefined; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts deleted file mode 100644 index 73d944db67..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getVoidLogger } from '@backstage/backend-common'; -import fs from 'fs-extra'; -import { FilePreparer } from './file'; -import os from 'os'; -import path from 'path'; - -jest.mock('fs-extra'); - -describe('File preparer', () => { - it('prepares templates from a file path', async () => { - const logger = getVoidLogger(); - const preparer = new FilePreparer(); - const root = os.platform() === 'win32' ? 'C:\\' : '/'; - const workspacePath = path.join(root, 'tmp'); - const targetPath = path.resolve(workspacePath, 'template'); - - await preparer.prepare({ - url: `file://${root}path/to/template`, - logger, - workspacePath, - }); - expect(fs.copy).toHaveBeenCalledWith( - path.join(root, 'path', 'to', 'template'), - targetPath, - { - recursive: true, - }, - ); - expect(fs.ensureDir).toHaveBeenCalledWith(targetPath); - - await expect( - preparer.prepare({ - url: 'http://not/file/path', - logger, - workspacePath, - }), - ).rejects.toThrow( - "Wrong location protocol, should be 'file', http://not/file/path", - ); - - if (os.platform() === 'win32') { - // eslint-disable-next-line jest/no-conditional-expect - await expect( - preparer.prepare({ - url: 'file:///unix/file/path', - logger, - workspacePath, - }), - ).rejects.toThrow('File URL path must be absolute'); - } else { - // eslint-disable-next-line jest/no-conditional-expect - await expect( - preparer.prepare({ - url: 'file://not/full/path', - logger, - workspacePath, - }), - ).rejects.toThrow( - `File URL host must be "localhost" or empty on ${os.platform()}`, - ); - } - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts deleted file mode 100644 index 92ee93511c..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { InputError } from '@backstage/errors'; -import { PreparerBase, PreparerOptions } from './types'; - -export class FilePreparer implements PreparerBase { - async prepare({ url, workspacePath }: PreparerOptions) { - if (!url.startsWith('file://')) { - throw new InputError(`Wrong location protocol, should be 'file', ${url}`); - } - - const templatePath = fileURLToPath(url); - - const targetDir = path.join(workspacePath, 'template'); - await fs.ensureDir(targetDir); - - await fs.copy(templatePath, targetDir, { - recursive: true, - }); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts deleted file mode 100644 index 0438e28f01..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -import { GithubPreparer } from './github'; -import { getVoidLogger, Git } from '@backstage/backend-common'; - -jest.mock('fs-extra'); - -describe('GitHubPreparer', () => { - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = path.resolve(workspacePath, 'checkout'); - const templatePath = path.resolve(workspacePath, 'template'); - - const mockGitClient = { - clone: jest.fn(), - }; - const logger = getVoidLogger(); - - jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const preparer = GithubPreparer.fromConfig({ - host: 'github.com', - token: 'fake-token', - }); - - it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare({ - url: - 'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template', - logger, - workspacePath, - }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: checkoutPath, - ref: expect.any(String), - }); - expect(fs.move).toHaveBeenCalledWith( - path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), - templatePath, - ); - expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); - }); - - it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - await preparer.prepare({ - url: - 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', - logger, - workspacePath, - }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: checkoutPath, - ref: 'master', - }); - expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); - }); - - it('calls the clone command with token', async () => { - await preparer.prepare({ - url: - 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', - logger, - workspacePath, - }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'x-access-token', - password: 'fake-token', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts deleted file mode 100644 index dec7fc06c2..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import path from 'path'; -import { Git } from '@backstage/backend-common'; -import { PreparerBase, PreparerOptions } from './types'; -import parseGitUrl from 'git-url-parse'; -import { - GitHubIntegrationConfig, - GithubCredentialsProvider, -} from '@backstage/integration'; - -export class GithubPreparer implements PreparerBase { - static fromConfig(config: GitHubIntegrationConfig) { - const credentialsProvider = GithubCredentialsProvider.create(config); - return new GithubPreparer({ credentialsProvider }); - } - - constructor( - private readonly config: { credentialsProvider: GithubCredentialsProvider }, - ) {} - - async prepare({ url, workspacePath, logger }: PreparerOptions) { - const parsedGitUrl = parseGitUrl(url); - const checkoutPath = path.join(workspacePath, 'checkout'); - const targetPath = path.join(workspacePath, 'template'); - const fullPathToTemplate = path.resolve( - checkoutPath, - parsedGitUrl.filepath ?? '', - ); - - const { token } = await this.config.credentialsProvider.getCredentials({ - url, - }); - - const git = token - ? Git.fromAuth({ - username: 'x-access-token', - password: token, - logger, - }) - : Git.fromAuth({ logger }); - - await git.clone({ - url: parsedGitUrl.toString('https'), - dir: checkoutPath, - ref: parsedGitUrl.ref, - }); - - await fs.move(fullPathToTemplate, targetPath); - - try { - await fs.rmdir(path.join(targetPath, '.git')); - } catch { - // Ignore intentionally - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts deleted file mode 100644 index fa76c74bd7..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -import { GitlabPreparer } from './gitlab'; -import { getVoidLogger, Git } from '@backstage/backend-common'; - -jest.mock('fs-extra'); - -describe('GitLabPreparer', () => { - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = path.resolve(workspacePath, 'checkout'); - const templatePath = path.resolve(workspacePath, 'template'); - - const mockGitClient = { - clone: jest.fn(), - }; - const logger = getVoidLogger(); - - jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - - beforeEach(() => { - jest.clearAllMocks(); - }); - const preparer = GitlabPreparer.fromConfig({ - host: '', - token: 'fake-token', - apiBaseUrl: '', - baseUrl: '', - }); - - it(`calls the clone command with the correct arguments for a repository`, async () => { - await preparer.prepare({ - url: - 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master', - logger, - workspacePath, - }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', - dir: checkoutPath, - ref: expect.any(String), - }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'oauth2', - password: 'fake-token', - }); - - expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); - }); - - it(`clones the template from a sub directory if specified`, async () => { - await preparer.prepare({ - url: - 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3', - logger, - workspacePath, - }); - expect(fs.move).toHaveBeenCalledWith( - path.resolve(checkoutPath, '1', '2', '3'), - templatePath, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts deleted file mode 100644 index 582214606b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Git } from '@backstage/backend-common'; -import { GitLabIntegrationConfig } from '@backstage/integration'; -import fs from 'fs-extra'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; -import { PreparerBase, PreparerOptions } from './types'; - -export class GitlabPreparer implements PreparerBase { - static fromConfig(config: GitLabIntegrationConfig) { - return new GitlabPreparer({ token: config.token }); - } - - constructor(private readonly config: { token?: string }) {} - - async prepare({ url, workspacePath, logger }: PreparerOptions) { - const parsedGitUrl = parseGitUrl(url); - const checkoutPath = path.join(workspacePath, 'checkout'); - const targetPath = path.join(workspacePath, 'template'); - const fullPathToTemplate = path.resolve( - checkoutPath, - parsedGitUrl.filepath ?? '', - ); - parsedGitUrl.git_suffix = true; - - const git = this.config.token - ? Git.fromAuth({ - password: this.config.token, - username: 'oauth2', - logger, - }) - : Git.fromAuth({ logger }); - - await git.clone({ - url: parsedGitUrl.toString('https'), - dir: checkoutPath, - ref: parsedGitUrl.ref, - }); - - await fs.move(fullPathToTemplate, targetPath); - - try { - await fs.rmdir(path.join(targetPath, '.git')); - } catch { - // Ignore intentionally - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts deleted file mode 100644 index 20dfad924b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { GithubPreparer } from './github'; -import { Preparers } from './preparers'; - -describe('Preparers', () => { - it('should return the correct preparer based on the hostname', async () => { - const preparer = await GithubPreparer.fromConfig({ - host: 'github.com', - apiBaseUrl: 'lols', - token: 'something else yo', - }); - - const preparers = new Preparers(); - preparers.register('github.com', preparer); - - expect( - preparers.get('https://github.com/please/find/me/something/from/github'), - ).toBe(preparer); - }); - - it('should throw an error if there is nothing that will match the url provided', async () => { - const preparer = await GithubPreparer.fromConfig({ - host: 'github.com', - apiBaseUrl: 'lols', - token: 'something else yo', - }); - - const preparers = new Preparers(); - preparers.register('github.com', preparer); - - expect(() => preparers.get('https://404.com')).toThrow( - `Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts deleted file mode 100644 index 98ca984c17..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Config } from '@backstage/config'; -import { PreparerBase, PreparerBuilder } from './types'; -import { Logger } from 'winston'; - -import { GitlabPreparer } from './gitlab'; -import { AzurePreparer } from './azure'; -import { GithubPreparer } from './github'; -import { BitbucketPreparer } from './bitbucket'; -import { ScmIntegrations } from '@backstage/integration'; - -export class Preparers implements PreparerBuilder { - private preparerMap = new Map(); - - register(host: string, preparer: PreparerBase) { - this.preparerMap.set(host, preparer); - } - - get(url: string): PreparerBase { - const preparer = this.preparerMap.get(new URL(url).host); - if (!preparer) { - throw new Error( - `Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`, - ); - } - return preparer; - } - - static async fromConfig( - config: Config, - // eslint-disable-next-line - _: { logger: Logger }, - ): Promise { - const preparers = new Preparers(); - const scm = ScmIntegrations.fromConfig(config); - for (const integration of scm.azure.list()) { - preparers.register( - integration.config.host, - AzurePreparer.fromConfig(integration.config), - ); - } - - for (const integration of scm.github.list()) { - preparers.register( - integration.config.host, - GithubPreparer.fromConfig(integration.config), - ); - } - - for (const integration of scm.gitlab.list()) { - preparers.register( - integration.config.host, - GitlabPreparer.fromConfig(integration.config), - ); - } - - for (const integration of scm.bitbucket.list()) { - preparers.register( - integration.config.host, - BitbucketPreparer.fromConfig(integration.config), - ); - } - - return preparers; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts deleted file mode 100644 index 3eecab33b5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -jest.mock('./helpers'); - -jest.mock('azure-devops-node-api', () => ({ - WebApi: jest.fn(), - getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), -})); - -import os from 'os'; -import { resolve } from 'path'; -import { AzurePublisher } from './azure'; -import { WebApi } from 'azure-devops-node-api'; -import * as helpers from './helpers'; -import { getVoidLogger } from '@backstage/backend-common'; - -describe('Azure Publisher', () => { - const logger = getVoidLogger(); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = resolve(workspacePath, 'result'); - - describe('publish: createRemoteInAzure', () => { - it('should use azure-devops-node-api to create a repo in the given project', async () => { - const mockGitClient = { - createRepository: jest.fn(), - }; - const mockGitApi = { - getGitApi: jest.fn().mockReturnValue(mockGitClient), - }; - - ((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi); - - const publisher = await AzurePublisher.fromConfig({ - host: 'dev.azure.com', - token: 'fake-azure-token', - }); - - mockGitClient.createRepository.mockResolvedValue({ - remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', - } as { remoteUrl: string }); - - const result = await publisher!.publish({ - values: { - storePath: 'https://dev.azure.com/organisation/project/_git/repo', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(WebApi).toHaveBeenCalledWith( - 'https://dev.azure.com/organisation', - expect.any(Function), - ); - - expect(result).toEqual({ - remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', - catalogInfoUrl: - 'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml', - }); - expect(mockGitClient.createRepository).toHaveBeenCalledWith( - { - name: 'repo', - }, - 'project', - ); - expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', - auth: { username: 'notempty', password: 'fake-azure-token' }, - logger, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts deleted file mode 100644 index 921c139a2e..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { IGitApi } from 'azure-devops-node-api/GitApi'; -import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { initRepoAndPush } from './helpers'; -import { AzureIntegrationConfig } from '@backstage/integration'; -import parseGitUrl from 'git-url-parse'; -import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; -import path from 'path'; - -export class AzurePublisher implements PublisherBase { - static async fromConfig(config: AzureIntegrationConfig) { - if (!config.token) { - return undefined; - } - return new AzurePublisher({ token: config.token }); - } - - constructor(private readonly config: { token: string }) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner, name, organization, resource } = parseGitUrl( - values.storePath, - ); - const authHandler = getPersonalAccessTokenHandler(this.config.token); - const webApi = new WebApi( - `https://${resource}/${organization}`, - authHandler, - ); - const client = await webApi.getGitApi(); - - const remoteUrl = await this.createRemote({ - project: owner, - name, - client, - }); - - const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl, - auth: { - username: 'notempty', - password: this.config.token, - }, - logger, - }); - - return { remoteUrl, catalogInfoUrl }; - } - - private async createRemote(opts: { - name: string; - project: string; - client: IGitApi; - }) { - const { name, project, client } = opts; - const createOptions: GitRepositoryCreateOptions = { name }; - const repo = await client.createRepository(createOptions, project); - - return repo.remoteUrl || ''; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts deleted file mode 100644 index a3f02e2c89..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('./helpers'); - -import os from 'os'; -import { resolve } from 'path'; -import { BitbucketPublisher } from './bitbucket'; -import { initRepoAndPush } from './helpers'; -import { getVoidLogger } from '@backstage/backend-common'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { msw } from '@backstage/test-utils'; - -describe('Bitbucket Publisher', () => { - const logger = getVoidLogger(); - const server = setupServer(); - msw.setupDefaultHandlers(server); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = resolve(workspacePath, 'result'); - - describe('publish: createRemoteInBitbucketCloud', () => { - it('should create repo in bitbucket cloud', async () => { - server.use( - rest.post( - 'https://api.bitbucket.org/2.0/repositories/project/repo', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json({ - links: { - html: { - href: 'https://bitbucket.org/project/repo', - }, - clone: [ - { - name: 'https', - href: 'https://bitbucket.org/project/repo', - }, - ], - }, - }), - ), - ), - ); - - const publisher = await BitbucketPublisher.fromConfig( - { - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-token', - }, - { repoVisibility: 'private' }, - ); - - const result = await publisher.publish({ - values: { - storePath: 'https://bitbucket.org/project/repo', - owner: 'bob', - }, - workspacePath, - logger: logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://bitbucket.org/project/repo', - catalogInfoUrl: - 'https://bitbucket.org/project/repo/src/master/catalog-info.yaml', - }); - - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://bitbucket.org/project/repo', - auth: { username: 'fake-user', password: 'fake-token' }, - logger: logger, - }); - }); - }); - - describe('publish: createRemoteInBitbucketServer', () => { - it('should throw an error if no username present', async () => { - await expect( - BitbucketPublisher.fromConfig( - { - host: 'bitbucket.mycompany.com', - token: 'fake-token', - }, - { repoVisibility: 'private' }, - ), - ).rejects.toThrow( - 'Bitbucket server requires the username to be set in your config', - ); - }); - it('should create repo in bitbucket server', async () => { - server.use( - rest.post( - 'https://bitbucket.mycompany.com/rest/api/1.0/projects/project/repos', - (_, res, ctx) => - res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({ - links: { - self: [ - { - href: - 'https://bitbucket.mycompany.com/projects/project/repos/repo', - }, - ], - clone: [ - { - name: 'http', - href: 'https://bitbucket.mycompany.com/scm/project/repo', - }, - ], - }, - }), - ), - ), - ); - - const publisher = await BitbucketPublisher.fromConfig( - { - host: 'bitbucket.mycompany.com', - username: 'foo', - token: 'fake-token', - }, - { repoVisibility: 'private' }, - ); - - const result = await publisher.publish({ - values: { - storePath: 'https://bitbucket.mycompany.com/project/repo', - owner: 'bob', - }, - workspacePath, - logger: logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', - catalogInfoUrl: - 'https://bitbucket.mycompany.com/projects/project/repos/repo/catalog-info.yaml', - }); - - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', - auth: { username: 'foo', password: 'fake-token' }, - logger: logger, - }); - }); - }); - - it('should use apiBaseUrl to create the repository if it is set', async () => { - server.use( - rest.post( - 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0/projects/project/repos', - (_, res, ctx) => - res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({ - links: { - self: [ - { - href: - 'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo', - }, - ], - clone: [ - { - name: 'http', - href: - 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo', - }, - ], - }, - }), - ), - ), - ); - - const publisher = await BitbucketPublisher.fromConfig( - { - host: 'bitbucket.mycompany.com', - username: 'foo', - token: 'fake-token', - apiBaseUrl: 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0', - }, - { repoVisibility: 'private' }, - ); - - const result = await publisher.publish({ - values: { - storePath: 'https://bitbucket.mycompany.com/project/repo', - owner: 'bob', - }, - workspacePath, - logger: logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo', - catalogInfoUrl: - 'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo/catalog-info.yaml', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts deleted file mode 100644 index a85518eeae..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { initRepoAndPush } from './helpers'; -import fetch from 'cross-fetch'; -import { BitbucketIntegrationConfig } from '@backstage/integration'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; - -export type RepoVisibilityOptions = 'private' | 'public'; - -// TODO(blam): We should probably start to use a bitbucket client here that we can change -// the baseURL to point at on-prem or public bitbucket versions like we do for -// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using -// a supported bitbucket client if one exists. -export class BitbucketPublisher implements PublisherBase { - static async fromConfig( - config: BitbucketIntegrationConfig, - { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, - ) { - if (config.host !== 'bitbucket.org' && !config.username) - throw new Error( - 'Bitbucket server requires the username to be set in your config', - ); - - return new BitbucketPublisher({ - host: config.host, - token: config.token, - appPassword: config.appPassword, - username: config.username, - apiBaseUrl: config.apiBaseUrl, - repoVisibility, - }); - } - - constructor( - private readonly config: { - host: string; - token?: string; - appPassword?: string; - username?: string; - apiBaseUrl?: string; - repoVisibility: RepoVisibilityOptions; - }, - ) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner: project, name } = parseGitUrl(values.storePath); - - const description = values.description as string; - const result = await this.createRemote({ - project, - name, - description, - }); - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl: result.remoteUrl, - auth: { - username: this.config.username ? this.config.username : 'x-token-auth', - password: this.config.appPassword - ? this.config.appPassword - : this.config.token ?? '', - }, - logger, - }); - return result; - } - - private async createRemote(opts: { - project: string; - name: string; - description: string; - }): Promise { - if (this.config.host === 'bitbucket.org') { - return this.createBitbucketCloudRepository(opts); - } - return this.createBitbucketServerRepository(opts); - } - - private async createBitbucketCloudRepository(opts: { - project: string; - name: string; - description: string; - }): Promise { - const { project, name, description } = opts; - - let response: Response; - - const options: RequestInit = { - method: 'POST', - body: JSON.stringify({ - scm: 'git', - description: description, - is_private: this.config.repoVisibility === 'private', - }), - headers: { - Authorization: this.getAuthorizationHeader(), - 'Content-Type': 'application/json', - }, - }; - try { - response = await fetch( - `https://api.bitbucket.org/2.0/repositories/${project}/${name}`, - options, - ); - } catch (e) { - throw new Error(`Unable to create repository, ${e}`); - } - if (response.status === 200) { - const r = await response.json(); - let remoteUrl = ''; - for (const link of r.links.clone) { - if (link.name === 'https') { - remoteUrl = link.href; - } - } - - // TODO use the urlReader to get the default branch - const catalogInfoUrl = `${r.links.html.href}/src/master/catalog-info.yaml`; - return { remoteUrl, catalogInfoUrl }; - } - throw new Error(`Not a valid response code ${await response.text()}`); - } - - private getAuthorizationHeader(): string { - if (this.config.username && this.config.appPassword) { - const buffer = Buffer.from( - `${this.config.username}:${this.config.appPassword}`, - 'utf8', - ); - - return `Basic ${buffer.toString('base64')}`; - } - - if (this.config.token) { - return `Bearer ${this.config.token}`; - } - - throw new Error( - `Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`, - ); - } - - private async createBitbucketServerRepository(opts: { - project: string; - name: string; - description: string; - }): Promise { - const { project, name, description } = opts; - - let response: Response; - const options: RequestInit = { - method: 'POST', - body: JSON.stringify({ - name: name, - description: description, - public: this.config.repoVisibility === 'public', - }), - headers: { - Authorization: this.getAuthorizationHeader(), - 'Content-Type': 'application/json', - }, - }; - - try { - const baseUrl = this.config.apiBaseUrl - ? this.config.apiBaseUrl - : `https://${this.config.host}/rest/api/1.0`; - - response = await fetch(`${baseUrl}/projects/${project}/repos`, options); - } catch (e) { - throw new Error(`Unable to create repository, ${e}`); - } - if (response.status === 201) { - const r = await response.json(); - let remoteUrl = ''; - for (const link of r.links.clone) { - if (link.name === 'http') { - remoteUrl = link.href; - } - } - const catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`; - return { remoteUrl, catalogInfoUrl }; - } - throw new Error(`Not a valid response code ${await response.text()}`); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts deleted file mode 100644 index fd4256dc86..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('@octokit/rest'); -jest.mock('./helpers'); - -import os from 'os'; -import { resolve } from 'path'; - -import { getVoidLogger } from '@backstage/backend-common'; -import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; -import { GithubPublisher } from './github'; -import { initRepoAndPush } from './helpers'; - -const { mockGithubClient } = require('@octokit/rest') as { - mockGithubClient: { - repos: jest.Mocked; - users: jest.Mocked; - teams: jest.Mocked; - }; -}; - -describe('GitHub Publisher', () => { - const logger = getVoidLogger(); - beforeEach(() => { - jest.clearAllMocks(); - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = resolve(workspacePath, 'result'); - - describe('with public repo visibility', () => { - describe('publish: createRemoteInGithub', () => { - it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createInOrg']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'blam/team', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ - org: 'blam', - name: 'test', - private: false, - visibility: 'public', - }); - expect( - mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg, - ).toHaveBeenCalledWith({ - org: 'blam', - team_slug: 'team', - owner: 'blam', - repo: 'test', - permission: 'admin', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - - it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'blam', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - name: 'test', - private: false, - }); - expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); - - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - it('should invite other user in the authed user', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'bob', - description: 'description', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - description: 'description', - name: 'test', - private: false, - }); - expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({ - owner: 'blam', - repo: 'test', - username: 'bob', - permission: 'admin', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - describe('with internal repo visibility', () => { - it('creates a private repository in the organization with visibility set to internal', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'internal' }, - ); - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createInOrg']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - isOrg: true, - storePath: 'https://github.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ - org: 'blam', - name: 'test', - private: true, - visibility: 'internal', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - describe('private visibility in a user account', () => { - it('creates a private repository', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'private' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - name: 'test', - private: true, - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts deleted file mode 100644 index 9b4b1d48f5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { - enableBranchProtectionOnDefaultRepoBranch, - initRepoAndPush, -} from './helpers'; -import { - GitHubIntegrationConfig, - GithubCredentialsProvider, -} from '@backstage/integration'; -import parseGitUrl from 'git-url-parse'; -import { Octokit } from '@octokit/rest'; -import path from 'path'; - -export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -/** @deprecated use createPublishGithubAction instead */ -export class GithubPublisher implements PublisherBase { - static async fromConfig( - config: GitHubIntegrationConfig, - { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, - ) { - if (!config.token && !config.apps) { - return undefined; - } - - const credentialsProvider = GithubCredentialsProvider.create(config); - - return new GithubPublisher({ - credentialsProvider, - repoVisibility, - apiBaseUrl: config.apiBaseUrl, - }); - } - - constructor( - private readonly config: { - credentialsProvider: GithubCredentialsProvider; - repoVisibility: RepoVisibilityOptions; - apiBaseUrl: string | undefined; - }, - ) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner, name } = parseGitUrl(values.storePath); - - const { token } = await this.config.credentialsProvider.getCredentials({ - url: values.storePath, - }); - - if (!token) { - throw new Error( - `No token could be acquired for URL: ${values.storePath}`, - ); - } - - const client = new Octokit({ - auth: token, - baseUrl: this.config.apiBaseUrl, - previews: ['nebula-preview'], - }); - - const description = values.description as string; - const access = values.access as string; - const remoteUrl = await this.createRemote({ - client, - description, - access, - name, - owner, - }); - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl, - auth: { - username: 'x-access-token', - password: token, - }, - logger, - }); - - const catalogInfoUrl = remoteUrl.replace( - /\.git$/, - '/blob/master/catalog-info.yaml', - ); - - try { - await enableBranchProtectionOnDefaultRepoBranch({ - owner, - client, - repoName: name, - logger, - }); - } catch (e) { - throw new Error(`Failed to add branch protection to '${name}', ${e}`); - } - - return { remoteUrl, catalogInfoUrl }; - } - - private async createRemote(opts: { - client: Octokit; - access: string; - name: string; - owner: string; - description: string; - }) { - const { client, access, description, owner, name } = opts; - - const user = await client.users.getByUsername({ - username: owner, - }); - - const repoCreationPromise = - user.data.type === 'Organization' - ? client.repos.createInOrg({ - name, - org: owner, - private: this.config.repoVisibility !== 'public', - visibility: this.config.repoVisibility, - description, - }) - : client.repos.createForAuthenticatedUser({ - name, - private: this.config.repoVisibility === 'private', - description, - }); - - const { data: newRepo } = await repoCreationPromise; - - try { - if (access?.startsWith(`${owner}/`)) { - const [, team] = access.split('/'); - await client.teams.addOrUpdateRepoPermissionsInOrg({ - org: owner, - team_slug: team, - owner, - repo: name, - permission: 'admin', - }); - // no need to add access if it's the person who owns the personal account - } else if (access && access !== owner) { - await client.repos.addCollaborator({ - owner, - repo: name, - username: access, - permission: 'admin', - }); - } - } catch (e) { - throw new Error( - `Failed to add access to '${access}'. Status ${e.status} ${e.message}`, - ); - } - - return newRepo.clone_url; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts deleted file mode 100644 index 5c6a01afc8..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('@gitbeaker/node', () => ({ - Gitlab: jest.fn(), -})); - -jest.mock('./helpers'); - -import os from 'os'; -import path from 'path'; -import { GitlabPublisher } from './gitlab'; -import { Gitlab } from '@gitbeaker/node'; -import { initRepoAndPush } from './helpers'; -import { getVoidLogger } from '@backstage/backend-common'; - -describe('GitLab Publisher', () => { - const logger = getVoidLogger(); - const mockGitlabClient = { - Namespaces: { - show: jest.fn(), - }, - Projects: { - create: jest.fn(), - }, - Users: { - current: jest.fn(), - }, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - ((Gitlab as unknown) as jest.Mock).mockImplementation( - () => mockGitlabClient, - ); - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = path.resolve(workspacePath, 'result'); - - describe('publish: createRemoteInGitLab', () => { - it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { - const publisher = await GitlabPublisher.fromConfig( - { - host: 'gitlab.com', - apiBaseUrl: 'https://gitlab.com/api/v4', - token: 'fake-token', - baseUrl: 'https://gitlab.hosted.com', - }, - { repoVisibility: 'public' }, - ); - - mockGitlabClient.Namespaces.show.mockResolvedValue({ - id: 42, - } as { id: number }); - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - const result = await publisher!.publish({ - values: { - isOrg: true, - storePath: 'https://gitlab.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(Gitlab).toHaveBeenCalledWith({ - token: 'fake-token', - host: 'https://gitlab.hosted.com', - }); - expect(result).toEqual({ - remoteUrl: 'mockclone', - catalogInfoUrl: 'mockclone', - }); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ - namespace_id: 42, - name: 'test', - visibility: 'public', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'mockclone', - auth: { username: 'oauth2', password: 'fake-token' }, - logger, - }); - }); - - it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { - const publisher = await GitlabPublisher.fromConfig( - { - host: 'gitlab.com', - apiBaseUrl: 'https://gitlab.com/api/v4', - token: 'fake-token', - baseUrl: 'https://gitlab.com', - }, - { repoVisibility: 'public' }, - ); - - mockGitlabClient.Namespaces.show.mockResolvedValue({}); - mockGitlabClient.Users.current.mockResolvedValue({ - id: 21, - } as { id: number }); - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - const result = await publisher!.publish({ - values: { - storePath: 'https://gitlab.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'mockclone', - catalogInfoUrl: 'mockclone', - }); - expect(mockGitlabClient.Users.current).toHaveBeenCalled(); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ - namespace_id: 21, - name: 'test', - visibility: 'public', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'mockclone', - auth: { username: 'oauth2', password: 'fake-token' }, - logger, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts deleted file mode 100644 index d2f58900a8..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Gitlab } from '@gitbeaker/node'; -import { Gitlab as GitlabClient } from '@gitbeaker/core'; -import { initRepoAndPush } from './helpers'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; -import { GitLabIntegrationConfig } from '@backstage/integration'; - -export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -export class GitlabPublisher implements PublisherBase { - static async fromConfig( - config: GitLabIntegrationConfig, - { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, - ) { - if (!config.token) { - return undefined; - } - - const client = new Gitlab({ host: config.baseUrl, token: config.token }); - return new GitlabPublisher({ - token: config.token, - client, - repoVisibility, - }); - } - - constructor( - private readonly config: { - token: string; - client: GitlabClient; - repoVisibility: RepoVisibilityOptions; - }, - ) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner, name } = parseGitUrl(values.storePath); - - const remoteUrl = await this.createRemote({ - owner, - name, - }); - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl, - auth: { - username: 'oauth2', - password: this.config.token, - }, - logger, - }); - - const catalogInfoUrl = remoteUrl.replace( - /\.git$/, - '/-/blob/master/catalog-info.yaml', - ); - return { remoteUrl, catalogInfoUrl }; - } - - private async createRemote(opts: { name: string; owner: string }) { - const { owner, name } = opts; - - // TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high! - // Shouldn't have to cast things now - let targetNamespace = ((await this.config.client.Namespaces.show( - owner, - )) as { - id: number; - }).id; - - if (!targetNamespace) { - targetNamespace = ((await this.config.client.Users.current()) as { - id: number; - }).id; - } - - const project = (await this.config.client.Projects.create({ - namespace_id: targetNamespace, - name: name, - visibility: this.config.repoVisibility, - })) as { http_url_to_repo: string }; - - return project?.http_url_to_repo; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts deleted file mode 100644 index 7a384f1657..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { AzurePublisher } from './azure'; -export { BitbucketPublisher } from './bitbucket'; -export { GithubPublisher } from './github'; -export type { RepoVisibilityOptions } from './github'; -export { GitlabPublisher } from './gitlab'; -export { Publishers } from './publishers'; -export type { - PublisherBase, - PublisherBuilder, - PublisherOptions, - PublisherResult, -} from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts deleted file mode 100644 index c7ca832752..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Publishers } from './publishers'; -import { GithubPublisher } from './github'; -import { getVoidLogger } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; -import { AzurePublisher } from './azure'; -import { GitlabPublisher } from './gitlab'; -import { BitbucketPublisher } from './bitbucket'; - -jest.mock('@octokit/rest'); -jest.mock('azure-devops-node-api'); - -describe('Publishers', () => { - const logger = getVoidLogger(); - - it('should throw an error when the publisher for the source location is not registered', () => { - const publishers = new Publishers(); - - expect(() => publishers.get('https://github.com/org/repo')).toThrow( - expect.objectContaining({ - message: - 'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config', - }), - ); - }); - - it('should return the correct preparer when the source matches for github', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - github: [{ host: 'github.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - - expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf( - GithubPublisher, - ); - }); - - it('should return the correct preparer when the source matches for azure', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - azure: [{ host: 'dev.azure.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - - expect( - publishers.get('https://dev.azure.com/org/project/_git/repo'), - ).toBeInstanceOf(AzurePublisher); - }); - - it('should return the correct preparer when the source matches for bitbucket', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - bitbucket: [ - { host: 'bitbucket.com', username: 'foo', token: 'blob' }, - ], - }, - }), - { - logger, - }, - ); - expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf( - BitbucketPublisher, - ); - }); - - it('should return the correct preparer when the source matches for gitlab', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - gitlab: [{ host: 'gitlab.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf( - GitlabPublisher, - ); - }); - - it('should respect registrations for custom URLs for providers using the integrations config', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { host: 'my.special.github.enterprise.thing', token: 'lolghe' }, - ], - }, - }), - { - logger, - }, - ); - - expect( - publishers.get('https://my.special.github.enterprise.thing/org/repo'), - ).toBeInstanceOf(GithubPublisher); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts deleted file mode 100644 index 9c34ab5783..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Config } from '@backstage/config'; -import { PublisherBase, PublisherBuilder } from './types'; -import { - GithubPublisher, - RepoVisibilityOptions as GithubRepoVisibilityOptions, -} from './github'; -import { - GitlabPublisher, - RepoVisibilityOptions as GitlabRepoVisibilityOptions, -} from './gitlab'; -import { AzurePublisher } from './azure'; -import { - BitbucketPublisher, - RepoVisibilityOptions as BitbucketRepoVisibilityOptions, -} from './bitbucket'; -import { Logger } from 'winston'; -import { ScmIntegrations } from '@backstage/integration'; - -export class Publishers implements PublisherBuilder { - private publisherMap = new Map(); - - register(host: string, preparer: PublisherBase | undefined) { - this.publisherMap.set(host, preparer); - } - - get(url: string): PublisherBase { - const preparer = this.publisherMap.get(new URL(url).host); - if (!preparer) { - throw new Error( - `Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`, - ); - } - return preparer; - } - - static async fromConfig( - config: Config, - _options: { logger: Logger }, - ): Promise { - const publishers = new Publishers(); - - const scm = ScmIntegrations.fromConfig(config); - - for (const integration of scm.azure.list()) { - const publisher = await AzurePublisher.fromConfig(integration.config); - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.github.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.github.visibility', - ) ?? 'public') as GithubRepoVisibilityOptions; - - const publisher = await GithubPublisher.fromConfig(integration.config, { - repoVisibility, - }); - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.gitlab.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.gitlab.visibility', - ) ?? 'public') as GitlabRepoVisibilityOptions; - - const publisher = await GitlabPublisher.fromConfig(integration.config, { - repoVisibility, - }); - - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.bitbucket.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.bitbucket.visibility', - ) ?? 'public') as BitbucketRepoVisibilityOptions; - - const publisher = await BitbucketPublisher.fromConfig( - integration.config, - { - repoVisibility, - }, - ); - - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - return publishers; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts deleted file mode 100644 index 725816c918..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { TemplaterValues } from '../templater'; -import { Logger } from 'winston'; - -/** - * Publisher is in charge of taking a folder created by - * the templater, and pushing it to a remote storage - */ -export type PublisherBase = { - /** - * - * @param opts object containing the template entity from the service - * catalog, plus the values from the form and the directory that has - * been templated - */ - publish(opts: PublisherOptions): Promise; -}; - -export type PublisherOptions = { - values: TemplaterValues; - workspacePath: string; - logger: Logger; -}; - -export type PublisherResult = { - remoteUrl: string; - catalogInfoUrl?: string; -}; - -export type PublisherBuilder = { - register(host: string, publisher: PublisherBase): void; - get(storePath: string): PublisherBase; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts deleted file mode 100644 index 1db5b52ffa..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const runCommand = jest.fn(); -const commandExists = jest.fn(); - -jest.mock('./helpers', () => ({ runCommand })); -jest.mock('command-exists', () => commandExists); -jest.mock('fs-extra'); - -import { ContainerRunner } from '@backstage/backend-common'; -import fs from 'fs-extra'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; -import { PassThrough } from 'stream'; -import { CookieCutter } from './cookiecutter'; - -describe('CookieCutter Templater', () => { - const containerRunner: jest.Mocked = { - runContainer: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - commandExists.mockRejectedValue(null); - }); - - it('should write a cookiecutter.json file with the values from the entity', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - description: 'description', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate')); - expect(fs.writeJson).toBeCalledWith( - path.join('tempdir', 'template', 'cookiecutter.json'), - expect.objectContaining(values), - ); - }); - - it('should merge any value that is in the cookiecutter.json path already', async () => { - const existingJson = { - _copy_without_render: ['./github/workflows/*'], - }; - - jest - .spyOn(fs, 'readJSON') - .mockImplementationOnce(() => Promise.resolve(existingJson)); - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'something', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(fs.writeJSON).toBeCalledWith( - path.join('tempdir', 'template', 'cookiecutter.json'), - { - ...existingJson, - ...values, - destination: { - git: expect.objectContaining({ organization: 'org', name: 'repo' }), - }, - }, - ); - }); - - it('should throw an error if the cookiecutter json is malformed and not missing', async () => { - jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => { - throw new Error('BAM'); - }); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const templater = new CookieCutter({ containerRunner }); - await expect( - templater.run({ - workspacePath: 'tempdir', - values, - }), - ).rejects.toThrow('BAM'); - }); - - it('should run the correct docker container with the correct bindings for the volumes', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - jest - .spyOn(fs, 'realpath') - .mockImplementation(x => Promise.resolve(x.toString())); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith({ - imageName: 'spotify/backstage-cookiecutter', - command: 'cookiecutter', - args: ['--no-input', '-o', '/output', '/input', '--verbose'], - envVars: { HOME: '/tmp' }, - mountDirs: { - [path.join('tempdir', 'template')]: '/input', - [path.join('tempdir', 'intermediate')]: '/output', - }, - workingDir: '/input', - logStream: undefined, - }); - }); - - it('should run the docker container mentioned in configs, overriding the default', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - imageName: 'foo/cookiecutter-image-with-extensions', - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith( - expect.objectContaining({ - imageName: 'foo/cookiecutter-image-with-extensions', - }), - ); - }); - - it('should pass through the streamer to the run docker helper', async () => { - const stream = new PassThrough(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - logStream: stream, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith({ - imageName: 'spotify/backstage-cookiecutter', - command: 'cookiecutter', - args: ['--no-input', '-o', '/output', '/input', '--verbose'], - envVars: { HOME: '/tmp' }, - mountDirs: { - [path.join('tempdir', 'template')]: '/input', - [path.join('tempdir', 'intermediate')]: '/output', - }, - workingDir: '/input', - logStream: stream, - }); - }); - - describe('when cookiecutter is available', () => { - it('use the binary', async () => { - const stream = new PassThrough(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - commandExists.mockResolvedValueOnce(true); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - logStream: stream, - }); - - expect(runCommand).toHaveBeenCalledWith({ - command: 'cookiecutter', - args: expect.arrayContaining([ - '--no-input', - '-o', - path.join('tempdir', 'intermediate'), - path.join('tempdir', 'template'), - '--verbose', - ]), - logStream: stream, - }); - }); - }); - - describe('when nothing was generated', () => { - it('throws an error', async () => { - const stream = new PassThrough(); - - jest - .spyOn(fs, 'readdir') - .mockImplementationOnce(() => Promise.resolve([])); - - const templater = new CookieCutter({ containerRunner }); - await expect( - templater.run({ - workspacePath: 'tempdir', - values: { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }, - logStream: stream, - }), - ).rejects.toThrow(/No data generated by cookiecutter/); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts deleted file mode 100644 index 46bd9094ac..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ContainerRunner } from '@backstage/backend-common'; -import fs from 'fs-extra'; -import path from 'path'; -import * as yaml from 'yaml'; -import { TemplaterBase, TemplaterRunOptions } from '../types'; - -// TODO(blam): Replace with the universal import from github-actions after a release -// As it will break the E2E without it -const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; - -export class CreateReactAppTemplater implements TemplaterBase { - private readonly containerRunner: ContainerRunner; - - constructor({ containerRunner }: { containerRunner: ContainerRunner }) { - this.containerRunner = containerRunner; - } - - public async run({ - workspacePath, - values, - logStream, - }: TemplaterRunOptions): Promise { - const { - component_id: componentName, - use_typescript: withTypescript, - use_github_actions: withGithubActions, - description, - owner, - } = values; - const intermediateDir = path.join(workspacePath, 'template'); - await fs.ensureDir(intermediateDir); - - const mountDirs = { - [intermediateDir]: '/template', - [intermediateDir]: '/result', - }; - - await this.containerRunner.runContainer({ - imageName: 'node:lts-alpine', - command: ['npx'], - args: [ - 'create-react-app', - componentName as string, - withTypescript ? ' --template typescript' : '', - ], - mountDirs, - workingDir: '/result', - logStream: logStream, - // Set the home directory inside the container as something that applications can - // write to, otherwise they will just fail trying to write to / - envVars: { HOME: '/tmp' }, - }); - - // if cookiecutter was successful, intermediateDir will contain - // exactly one directory. - const [generated] = await fs.readdir(intermediateDir); - - if (generated === undefined) { - throw new Error('No data generated by cookiecutter'); - } - - const resultDir = path.join(workspacePath, 'result'); - await fs.move(path.join(intermediateDir, generated), resultDir); - - const extraAnnotations: Record = {}; - if (withGithubActions) { - await fs.mkdir(`${resultDir}/.github`); - await fs.mkdir(`${resultDir}/.github/workflows`); - const githubActionsYaml = ` -name: CRA Build - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [12.x] - - steps: - - name: checkout code - uses: actions/checkout@v1 - - name: get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 - with: - path: \${{ steps.yarn-cache.outputs.dir }} - key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }} - restore-keys: | - \${{ runner.os }}-yarn- - - name: use node.js \${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: \${{ matrix.node-version }} - - name: yarn install, build, and test - working-directory: . - run: | - yarn install - yarn build - yarn test - env: - CI: true - `; - await fs.writeFile( - `${resultDir}/.github/workflows/main.yml`, - githubActionsYaml, - ); - - extraAnnotations[ - GITHUB_ACTIONS_ANNOTATION - ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; - } - - const componentInfo = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: componentName, - description, - annotations: { - ...extraAnnotations, - }, - }, - spec: { - type: 'website', - lifecycle: 'experimental', - owner, - }, - }; - - await fs.writeFile( - `${resultDir}/catalog-info.yaml`, - yaml.stringify(componentInfo), - ); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts deleted file mode 100644 index c2742428a5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { spawn } from 'child_process'; -import { PassThrough, Writable } from 'stream'; - -export type RunCommandOptions = { - command: string; - args: string[]; - logStream?: Writable; -}; - -/** - * Gets the templater key to use for templating from the entity - * @param entity Template entity - */ -export const getTemplaterKey = (entity: TemplateEntityV1alpha1): string => { - const { templater } = entity.spec; - - if (!templater) { - throw new InputError('Template does not have a required templating key'); - } - - return templater; -}; - -/** - * - * @param options the options object - * @param options.command the command to run - * @param options.args the arguments to pass the command - * @param options.logStream the log streamer to capture log messages - */ -export const runCommand = async ({ - command, - args, - logStream = new PassThrough(), -}: RunCommandOptions) => { - await new Promise((resolve, reject) => { - const process = spawn(command, args); - - process.stdout.on('data', stream => { - logStream.write(stream); - }); - - process.stderr.on('data', stream => { - logStream.write(stream); - }); - - process.on('error', error => { - return reject(error); - }); - - process.on('close', code => { - if (code !== 0) { - return reject(`Command ${command} failed, exit code: ${code}`); - } - return resolve(); - }); - }); -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts deleted file mode 100644 index ae3890c092..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ContainerRunner } from '@backstage/backend-common'; -import { CookieCutter } from './cookiecutter'; -import { Templaters } from './templaters'; - -describe('Templaters', () => { - const containerRunner: jest.Mocked = { - runContainer: jest.fn(), - }; - - it('should throw an error when the templater is not registered', () => { - const templaters = new Templaters(); - - expect(() => templaters.get('cookiecutter')).toThrow( - expect.objectContaining({ - message: 'No templater registered for template: "cookiecutter"', - }), - ); - }); - it('should return the correct templater when the templater matches', () => { - const templaters = new Templaters(); - const templater = new CookieCutter({ containerRunner }); - - templaters.register('cookiecutter', templater); - - expect(templaters.get('cookiecutter')).toBe(templater); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts deleted file mode 100644 index a977502a01..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - TemplaterBase, - SupportedTemplatingKey, - TemplaterBuilder, -} from './types'; - -export class Templaters implements TemplaterBuilder { - private templaterMap = new Map(); - - register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) { - this.templaterMap.set(templaterKey, templater); - } - - get(templaterId: string): TemplaterBase { - const templater = this.templaterMap.get(templaterId); - - if (!templater) { - throw new Error(`No templater registered for template: "${templaterId}"`); - } - - return templater; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts deleted file mode 100644 index 4d6b96c776..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import gitUrlParse from 'git-url-parse'; -import type { Writable } from 'stream'; - -/** - * Currently the required template values. The owner - * and where to store the result from templating - */ -export type RequiredTemplateValues = { - owner: string; - storePath: string; - destination?: { - git?: gitUrlParse.GitUrl; - }; -}; - -export type TemplaterValues = RequiredTemplateValues & Record; - -/** - * The returned directory from the templater which is ready - * to pass to the next stage of the scaffolder which is publishing - */ -export type TemplaterRunResult = { - resultDir: string; -}; - -/** - * The values that the templater will receive. The directory of the - * skeleton, with the values from the frontend. A dedicated log stream and a docker - * client to run any templater on top of your directory. - */ -export type TemplaterRunOptions = { - workspacePath: string; - values: TemplaterValues; - logStream?: Writable; -}; - -export type TemplaterBase = { - // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; -}; - -export type TemplaterConfig = { - templater?: TemplaterBase; -}; - -/** - * List of supported templating options - */ -export type SupportedTemplatingKey = 'cookiecutter' | string; - -/** - * The templater builder holds the templaters ready for run time - */ -export type TemplaterBuilder = { - register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(templater: string): TemplaterBase; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts deleted file mode 100644 index fef92d081f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { resolve as resolvePath, dirname } from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import parseGitUrl from 'git-url-parse'; -import { TaskSpec } from './types'; -import { - getTemplaterKey, - joinGitUrlPath, - parseLocationAnnotation, - TemplaterValues, -} from '../stages'; - -export function templateEntityToSpec( - template: TemplateEntityV1alpha1, - inputValues: TemplaterValues, -): TaskSpec { - const steps: TaskSpec['steps'] = []; - - const { protocol, location } = parseLocationAnnotation(template); - - let url: string; - if (protocol === 'file') { - const path = resolvePath(dirname(location), template.spec.path || '.'); - - url = `file://${path}`; - } else { - url = joinGitUrlPath(location, template.spec.path); - } - const templater = getTemplaterKey(template); - - const values = { - ...inputValues, - destination: { - git: parseGitUrl(inputValues.storePath), - }, - } as TemplaterValues; - - steps.push({ - id: 'prepare', - name: 'Prepare', - action: 'legacy:prepare', - input: { - protocol, - url, - }, - }); - - steps.push({ - id: 'template', - name: 'Template', - action: 'legacy:template', - input: { - templater, - values, - }, - }); - - steps.push({ - id: 'publish', - name: 'Publish', - action: 'legacy:publish', - input: { - values, - }, - }); - - steps.push({ - id: 'register', - name: 'Register', - action: 'catalog:register', - input: { - catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', - }, - }); - - return { - baseUrl: undefined, // not used by legacy actions - values: {}, - steps, - output: { - remoteUrl: '{{ steps.publish.output.remoteUrl }}', - catalogInfoUrl: '{{ steps.register.output.catalogInfoUrl }}', - entityRef: '{{ steps.register.output.entityRef }}', - }, - }; -} diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index b43b62a413..c0fdcb2ef7 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -33,12 +33,13 @@ import { PluginDatabaseManager, DatabaseManager, UrlReaders, + DockerContainerRunner, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; -import { Preparers, Publishers, Templaters } from '../scaffolder'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { createRouter } from './router'; const createCatalogClient = (templates: any[] = []) => @@ -66,8 +67,8 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; - const template = { - apiVersion: 'backstage.io/v1alpha1', + const template: TemplateEntityV1beta2 = { + apiVersion: 'backstage.io/v1beta2', kind: 'Template', metadata: { description: 'Create a new CRA website project', @@ -80,42 +81,28 @@ describe('createRouter', () => { }, spec: { owner: 'web@example.com', - path: '.', - schema: { + type: 'website', + steps: [], + parameters: { + type: 'object', + required: ['required'], properties: { - component_id: { - description: 'Unique name of the component', - title: 'Name', + required: { type: 'string', - }, - description: { - description: 'Description of the component', - title: 'Description', - type: 'string', - }, - use_typescript: { - default: true, - description: 'Include TypeScript', - title: 'Use TypeScript', - type: 'boolean', + description: 'Required parameter', }, }, - required: ['component_id', 'use_typescript'], }, - templater: 'cra', - type: 'website', }, }; beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - preparers: new Preparers(), - templaters: new Templaters(), - publishers: new Publishers(), config: new ConfigReader({}), database: createDatabase(), catalogClient: createCatalogClient([template]), + containerRunner: new DockerContainerRunner({} as any), reader: mockUrlReader, }); app = express().use(router); @@ -139,7 +126,7 @@ describe('createRouter', () => { const response = await request(app) .post('/v2/tasks') .send({ - templateName: '', + templateName: 'create-react-app-template', values: { storePath: 'https://github.com/backstage/backstage', }, @@ -154,10 +141,7 @@ describe('createRouter', () => { .send({ templateName: 'create-react-app-template', values: { - storePath: 'https://github.com/backstage/backstage', - component_id: '123', - name: 'test', - use_typescript: false, + required: 'required-value', }, }); @@ -174,10 +158,7 @@ describe('createRouter', () => { .send({ templateName: 'create-react-app-template', values: { - storePath: 'https://github.com/backstage/backstage', - component_id: '123', - name: 'test', - use_typescript: false, + required: 'required-value', }, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index b0142d4b02..2b5bdb3bf8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -18,12 +18,6 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { - PreparerBuilder, - TemplaterBuilder, - TemplaterValues, - PublisherBuilder, -} from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; import { validate } from 'jsonschema'; import { @@ -31,27 +25,21 @@ import { StorageTaskBroker, TaskWorker, } from '../scaffolder/tasks'; -import { templateEntityToSpec } from '../scaffolder/tasks/TemplateConverter'; import { TemplateActionRegistry } from '../scaffolder/actions/TemplateActionRegistry'; -import { createLegacyActions } from '../scaffolder/stages/legacy'; import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; -import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; +import { + ContainerRunner, + PluginDatabaseManager, + UrlReader, +} from '@backstage/backend-common'; import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; -import { - TemplateEntityV1alpha1, - TemplateEntityV1beta2, - Entity, -} from '@backstage/catalog-model'; +import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '../scaffolder/actions'; import { createBuiltinActions } from '../scaffolder/actions/builtin/createBuiltinActions'; export interface RouterOptions { - preparers: PreparerBuilder; - templaters: TemplaterBuilder; - publishers: PublisherBuilder; - logger: Logger; config: Config; reader: UrlReader; @@ -59,19 +47,11 @@ export interface RouterOptions { catalogClient: CatalogApi; actions?: TemplateAction[]; taskWorkers?: number; -} - -function isAlpha1Template( - entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, -): entity is TemplateEntityV1alpha1 { - return ( - entity.apiVersion === 'backstage.io/v1alpha1' || - entity.apiVersion === 'backstage.io/v1beta1' - ); + containerRunner: ContainerRunner; } function isBeta2Template( - entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, + entity: TemplateEntityV1beta2, ): entity is TemplateEntityV1beta2 { return entity.apiVersion === 'backstage.io/v1beta2'; } @@ -83,15 +63,13 @@ export async function createRouter( router.use(express.json()); const { - preparers, - templaters, - publishers, logger: parentLogger, config, reader, database, catalogClient, actions, + containerRunner, taskWorkers, } = options; @@ -118,19 +96,13 @@ export async function createRouter( const actionsToRegister = Array.isArray(actions) ? actions - : [ - ...createLegacyActions({ - preparers, - publishers, - templaters, - }), - ...createBuiltinActions({ - integrations, - catalogClient, - templaters, - reader, - }), - ]; + : createBuiltinActions({ + integrations, + catalogClient, + containerRunner, + reader, + config, + }); actionsToRegister.forEach(action => actionRegistry.register(action)); workers.forEach(worker => worker.start()); @@ -164,42 +136,6 @@ export async function createRouter( schema, })), }); - } else if (isAlpha1Template(template)) { - res.json({ - title: template.metadata.title ?? template.metadata.name, - steps: [ - { - title: 'Fill in template parameters', - schema: template.spec.schema, - }, - { - title: 'Choose owner and repo', - schema: { - type: 'object', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: - 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', - }, - access: { - type: 'string', - title: 'Access', - description: - 'Who should have access, in org/team or user format', - }, - }, - }, - }, - ], - }); } else { throw new InputError( `Unsupported apiVersion field in schema entity, ${ @@ -221,26 +157,15 @@ export async function createRouter( }) .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; - const values: TemplaterValues = req.body.values; + const values = req.body.values; const token = getBearerToken(req.headers.authorization); const template = await entityClient.findTemplate(templateName, { token, }); let taskSpec; - if (isAlpha1Template(template)) { - logger.warn( - `[DEPRECATION] - Template: ${template.metadata.name} has version ${template.apiVersion} which is going to be deprecated. Please refer to https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2 for help on migrating`, - ); - const result = validate(values, template.spec.schema); - if (!result.valid) { - res.status(400).json({ errors: result.errors }); - return; - } - - taskSpec = templateEntityToSpec(template, values); - } else if (isBeta2Template(template)) { + if (isBeta2Template(template)) { for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(values, parameters); diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index ebe8a0c0ba..5a3231a133 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-scaffolder +## 0.9.10 + +### Patch Changes + +- 9e60a728e: Upgrade `rjsf` to 3.0.0. +- a94587cad: Update dependencies +- Updated dependencies + - @backstage/plugin-catalog-react@0.2.5 + - @backstage/core-components@0.1.4 + - @backstage/integration@0.5.7 + - @backstage/catalog-client@0.3.15 + ## 0.9.9 ### Patch Changes diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 5da1869fc2..7982ad997e 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -4,6 +4,7 @@ ```ts +import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -24,9 +25,21 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; // @public (undocumented) export function createScaffolderFieldExtension(options: FieldExtensionOptions): Extension<() => null>; +// @public (undocumented) +export type CustomFieldValidator = ((data: T, field: FieldValidation) => void) | ((data: T, field: FieldValidation, context: { + apiHolder: ApiHolder; +}) => void); + // @public (undocumented) export const EntityPickerFieldExtension: () => null; +// @public (undocumented) +export type FieldExtensionOptions = { + name: string; + component: (props: FieldProps) => JSX.Element | null; + validation?: CustomFieldValidator; +}; + // @public (undocumented) export const OwnerPickerFieldExtension: () => null; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 88845b402b..88d973377d 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.9", + "version": "0.9.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,21 +30,21 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.14", + "@backstage/catalog-client": "^0.3.15", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@backstage/integration-react": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.2.4", + "@backstage/plugin-catalog-react": "^0.2.5", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@rjsf/core": "^2.4.0", - "@rjsf/material-ui": "^2.4.0", + "@rjsf/core": "^3.0.0", + "@rjsf/material-ui": "^3.0.0", "@types/react": "^16.9", "classnames": "^2.2.6", "git-url-parse": "^11.4.4", @@ -58,12 +58,12 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", - "use-immer": "^0.5.1", + "use-immer": "^0.6.0", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 85df77f2f5..37d78d0fda 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -245,7 +245,10 @@ export class ScaffolderClient implements ScaffolderApi { */ async listActions(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const response = await fetch(`${baseUrl}/v2/actions`); + const token = await this.identityApi.getIdToken(); + const response = await fetch(`${baseUrl}/v2/actions`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!response.ok) { throw ResponseError.fromResponse(response); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts index 1031dfffe3..b51d01f719 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -286,4 +286,64 @@ describe('transformSchemaToProps', () => { uiSchema: expectedUiSchema, }); }); + + it('transforms schema with dependencies', () => { + const inputSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + credit_card: { + type: 'number', + }, + }, + required: ['name'], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: 'string', + 'ui:widget': 'textarea', + }, + }, + required: ['billing_address'], + }, + }, + }; + const expectedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + credit_card: { + type: 'number', + }, + }, + required: ['name'], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: 'string', + }, + }, + required: ['billing_address'], + }, + }, + }; + const expectedUiSchema = { + billing_address: { + 'ui:widget': 'textarea', + }, + credit_card: {}, + name: {}, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); }); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index 3342e3a046..ee2dbce406 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { return; } - const { properties, anyOf, oneOf, allOf } = schema; + const { properties, anyOf, oneOf, allOf, dependencies } = schema; for (const propName in schema) { if (!schema.hasOwnProperty(propName)) { @@ -81,6 +81,16 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { extractUiSchema(schemaNode, uiSchema); } } + + if (isObject(dependencies)) { + for (const depName of Object.keys(dependencies)) { + const schemaNode = dependencies[depName]; + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); + } + } } export function transformSchemaToProps( diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 1d16e50568..65386b60b3 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -14,7 +14,18 @@ * limitations under the License. */ -import { EntityMeta, TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { EntityMeta, TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + Header, + ItemCardGrid, + Lifecycle, + WarningPanel, + Page, + Progress, + SupportButton, +} from '@backstage/core-components'; import { useStarredEntities } from '@backstage/plugin-catalog-react'; import { Button, Link, makeStyles, Typography } from '@material-ui/core'; import StarIcon from '@material-ui/icons/Star'; @@ -30,18 +41,6 @@ import { TemplateCard } from '../TemplateCard'; import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; -import { - Content, - ContentHeader, - Header, - ItemCardGrid, - Lifecycle, - Page, - Progress, - SupportButton, - WarningPanel, -} from '@backstage/core-components'; - const useStyles = makeStyles(theme => ({ contentWrapper: { display: 'grid', @@ -90,7 +89,7 @@ export const ScaffolderPageContents = () => { ); const [search, setSearch] = useState(''); const [matchingEntities, setMatchingEntities] = useState( - [] as TemplateEntityV1alpha1[], + [] as TemplateEntityV1beta2[], ); const matchesQuery = (metadata: EntityMeta, query: string) => @@ -175,11 +174,7 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map((template, i) => ( - + ))}
diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index ad91fc6cfd..9d2e827be9 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -16,7 +16,7 @@ import { Entity, RELATION_OWNED_BY, - TemplateEntityV1alpha1, + TemplateEntityV1beta2, } from '@backstage/catalog-model'; import { ScmIntegrationIcon, @@ -93,7 +93,7 @@ const useDeprecationStyles = makeStyles(theme => ({ })); export type TemplateCardProps = { - template: TemplateEntityV1alpha1; + template: TemplateEntityV1beta2; deprecated?: boolean; }; @@ -106,7 +106,7 @@ type TemplateProps = { }; const getTemplateCardProps = ( - template: TemplateEntityV1alpha1, + template: TemplateEntityV1beta2, ): TemplateProps & { key: string } => { return { key: template.metadata.uid!, diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 3caece4878..e72255f129 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -15,14 +15,13 @@ */ import { JsonObject, JsonValue } from '@backstage/config'; import { LinearProgress } from '@material-ui/core'; -import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core'; -import parseGitUrl from 'git-url-parse'; +import { FormValidation, IChangeEvent } from '@rjsf/core'; import React, { useCallback, useState } from 'react'; import { generatePath, Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { FieldExtensionOptions } from '../../extensions'; +import { CustomFieldValidator, FieldExtensionOptions } from '../../extensions'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; @@ -33,7 +32,13 @@ import { Lifecycle, Page, } from '@backstage/core-components'; -import { errorApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { + ApiHolder, + errorApiRef, + useApi, + useApiHolder, + useRouteRef, +} from '@backstage/core-plugin-api'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -55,10 +60,10 @@ function isObject(obj: unknown): obj is JsonObject { export const createValidator = ( rootSchema: JsonObject, - validators: Record< - string, - undefined | ((value: JsonValue, validation: FieldValidation) => void) - >, + validators: Record>, + context: { + apiHolder: ApiHolder; + }, ) => { function validate( schema: JsonObject, @@ -87,7 +92,11 @@ export const createValidator = ( const fieldName = isObject(propSchema) && (propSchema['ui:field'] as string); if (fieldName && typeof validators[fieldName] === 'function') { - validators[fieldName]!(propData as JsonValue, propValidation); + validators[fieldName]!( + propData as JsonValue, + propValidation, + context, + ); } } } @@ -99,44 +108,12 @@ export const createValidator = ( }; }; -const storePathValidator = ( - formData: { storePath?: string }, - errors: FormValidation, -) => { - const { storePath } = formData; - if (!storePath) { - errors.storePath.addError('Store path is required and not present'); - return errors; - } - - try { - const parsedUrl = parseGitUrl(storePath); - - if (!parsedUrl.resource || !parsedUrl.owner || !parsedUrl.name) { - if (parsedUrl.resource === 'dev.azure.com') { - errors.storePath.addError( - "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", - ); - } else { - errors.storePath.addError( - 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', - ); - } - } - } catch (ex) { - errors.storePath.addError( - `Failed validation of the store path with message ${ex.message}`, - ); - } - - return errors; -}; - export const TemplatePage = ({ customFieldExtensions = [], }: { customFieldExtensions?: FieldExtensionOptions[]; }) => { + const apiHolder = useApiHolder(); const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); @@ -203,18 +180,13 @@ export const TemplatePage = ({ onReset={handleFormReset} onFinish={handleCreate} steps={schema.steps.map(step => { - // TODO: Can delete this function when the migration from v1 to v2 beta is completed - // And just have the default validator for all fields. - if ((step.schema as any)?.properties?.storePath) { - return { - ...step, - validate: (a, b) => storePathValidator(a, b), - }; - } - return { ...step, - validate: createValidator(step.schema, customFieldValidators), + validate: createValidator( + step.schema, + customFieldValidators, + { apiHolder }, + ), }; })} /> diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts index f0df198507..1ea66e7df9 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { repoPickerValidation } from './validation'; import { FieldValidation } from '@rjsf/core'; @@ -22,7 +23,7 @@ describe('RepoPicker Validation', () => { addError: jest.fn(), } as unknown) as FieldValidation); - it('validaties when no repo', () => { + it('validates when no repo', () => { const mockFieldValidation = fieldValidator(); repoPickerValidation('github.com?owner=a', mockFieldValidation); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts index 827afa3cab..760b9f7890 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { FieldValidation } from '@rjsf/core'; export const repoPickerValidation = ( diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index f53052cb67..131cd105da 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { FieldExtensionOptions } from './types'; +import { CustomFieldValidator, FieldExtensionOptions } from './types'; import { Extension, attachComponentData } from '@backstage/core-plugin-api'; export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1'; @@ -46,6 +46,6 @@ attachComponentData( true, ); -export type { FieldExtensionOptions }; +export type { CustomFieldValidator, FieldExtensionOptions }; export { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from './default'; diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts index 8f1c155f28..644897f7f4 100644 --- a/plugins/scaffolder/src/extensions/types.ts +++ b/plugins/scaffolder/src/extensions/types.ts @@ -13,10 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ApiHolder } from '@backstage/core-plugin-api'; import { FieldValidation, FieldProps } from '@rjsf/core'; +export type CustomFieldValidator = + | ((data: T, field: FieldValidation) => void) + | (( + data: T, + field: FieldValidation, + context: { apiHolder: ApiHolder }, + ) => void); + export type FieldExtensionOptions = { name: string; component: (props: FieldProps) => JSX.Element | null; - validation?: (data: T, field: FieldValidation) => void; + validation?: CustomFieldValidator; }; diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx index 7aa1a21643..a5be11380f 100644 --- a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAsyncFn } from 'react-use'; @@ -50,7 +50,7 @@ function useProvideEntityFilters(): FilterGroupsContext { const response = await catalogApi.getEntities({ filter: { kind: 'Template' }, }); - return response.items as TemplateEntityV1alpha1[]; + return response.items as TemplateEntityV1beta2[]; }); const filterGroups = useRef<{ @@ -64,7 +64,7 @@ function useProvideEntityFilters(): FilterGroupsContext { [filterGroupId: string]: FilterGroupStates; }>({}); const [filteredEntities, setFilteredEntities] = useState< - TemplateEntityV1alpha1[] + TemplateEntityV1beta2[] >([]); const [availableCategories, setAvailableCategories] = useState([]); const [isCatalogEmpty, setCatalogEmpty] = useState(false); @@ -161,7 +161,7 @@ function buildStates( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, selectedCategories: string[], - entities?: TemplateEntityV1alpha1[], + entities?: TemplateEntityV1beta2[], error?: Error, ): { [filterGroupId: string]: FilterGroupStates } { // On error - all entries are an error state @@ -208,7 +208,7 @@ function buildStates( } // Given all entites, find all possible categories and provide them in a sorted list. -function collectCategories(entities?: TemplateEntityV1alpha1[]): string[] { +function collectCategories(entities?: TemplateEntityV1beta2[]): string[] { const categories = new Set(); (entities || []).forEach(e => { if (e.spec?.type) { @@ -224,9 +224,9 @@ function buildMatchingEntities( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, selectedCategories: string[], - entities?: TemplateEntityV1alpha1[], + entities?: TemplateEntityV1beta2[], excludeFilterGroupId?: string, -): TemplateEntityV1alpha1[] { +): TemplateEntityV1beta2[] { // Build one filter fn per filter group const allFilters: EntityFilterFn[] = []; for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts index ee66e3d9da..385d3f6eb5 100644 --- a/plugins/scaffolder/src/filter/context.ts +++ b/plugins/scaffolder/src/filter/context.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { createContext } from 'react'; import { FilterGroup, FilterGroupStates } from './types'; @@ -32,7 +32,7 @@ export type FilterGroupsContext = { loading: boolean; error?: Error; filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; - filteredEntities: TemplateEntityV1alpha1[]; + filteredEntities: TemplateEntityV1beta2[]; availableCategories: string[]; isCatalogEmpty: boolean; }; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 6025abc067..74091a1a85 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -20,6 +20,7 @@ export { createScaffolderFieldExtension, ScaffolderFieldExtensions, } from './extensions'; +export type { CustomFieldValidator, FieldExtensionOptions } from './extensions'; export { EntityPickerFieldExtension, OwnerPickerFieldExtension, diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md index 03585b6b99..ccb7475573 100644 --- a/plugins/search-backend-node/api-report.md +++ b/plugins/search-backend-node/api-report.md @@ -7,7 +7,7 @@ import { DocumentCollator } from '@backstage/search-common'; import { DocumentDecorator } from '@backstage/search-common'; import { IndexableDocument } from '@backstage/search-common'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { default as lunr_2 } from 'lunr'; import { SearchQuery } from '@backstage/search-common'; import { SearchResultSet } from '@backstage/search-common'; @@ -27,14 +27,14 @@ export class IndexBuilder { // @public (undocumented) export class LunrSearchEngine implements SearchEngine { constructor({ logger }: { - logger: Logger; + logger: Logger_2; }); // (undocumented) protected docStore: Record; // (undocumented) index(type: string, documents: IndexableDocument[]): void; // (undocumented) - protected logger: Logger; + protected logger: Logger_2; // (undocumented) protected lunrIndices: Record; // (undocumented) @@ -48,7 +48,7 @@ export class LunrSearchEngine implements SearchEngine { // @public export class Scheduler { constructor({ logger }: { - logger: Logger; + logger: Logger_2; }); addToSchedule(task: Function, interval: number): void; start(): void; diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts index 6c0b748b03..3e356aa6aa 100644 --- a/plugins/search-backend-node/src/Scheduler.ts +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -15,6 +15,7 @@ */ import { Logger } from 'winston'; +import { runPeriodically } from './runPeriodically'; type TaskEnvelope = { task: Function; @@ -28,7 +29,7 @@ type TaskEnvelope = { export class Scheduler { private logger: Logger; private schedule: TaskEnvelope[]; - private intervalTimeouts: NodeJS.Timeout[] = []; + private runningTasks: Function[] = []; constructor({ logger }: { logger: Logger }) { this.logger = logger; @@ -36,11 +37,12 @@ export class Scheduler { } /** - * Adds each task and interval to the schedule - * + * Adds each task and interval to the schedule. + * When running the tasks, the scheduler waits at least for the time specified + * in the interval once the task was completed, before running it again. */ addToSchedule(task: Function, interval: number) { - if (this.intervalTimeouts.length) { + if (this.runningTasks.length) { throw new Error( 'Cannot add task to schedule that has already been started.', ); @@ -54,13 +56,7 @@ export class Scheduler { start() { this.logger.info('Starting all scheduled search tasks.'); this.schedule.forEach(({ task, interval }) => { - // Fire the task immediately, then schedule it. - task(); - this.intervalTimeouts.push( - setInterval(() => { - task(); - }, interval), - ); + this.runningTasks.push(runPeriodically(() => task(), interval)); }); } @@ -69,9 +65,9 @@ export class Scheduler { */ stop() { this.logger.info('Stopping all scheduled search tasks.'); - this.intervalTimeouts.forEach(timeout => { - clearInterval(timeout); + this.runningTasks.forEach(cancel => { + cancel(); }); - this.intervalTimeouts = []; + this.runningTasks = []; } } diff --git a/plugins/search-backend-node/src/runPeriodically.test.ts b/plugins/search-backend-node/src/runPeriodically.test.ts new file mode 100644 index 0000000000..4d8e991b9e --- /dev/null +++ b/plugins/search-backend-node/src/runPeriodically.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { runPeriodically } from './runPeriodically'; + +jest.useFakeTimers(); + +describe('runPeriodically', () => { + const flushPromises = () => new Promise(setImmediate); + const advanceTimersByTime = async (time: number) => { + jest.advanceTimersByTime(time); + // Advancing the time with jest doesn't run all promises, but only sync code + await flushPromises(); + }; + + it('runs task initially', async () => { + const task = jest.fn(async () => {}); + const cancel = runPeriodically(task, 1000); + expect(task).toHaveBeenCalledTimes(1); + cancel(); + }); + + it('runs at requested interval', async () => { + const task = jest.fn(async () => {}); + const cancel = runPeriodically(task, 1000); + await flushPromises(); + await advanceTimersByTime(1000); + await advanceTimersByTime(1000); + expect(task).toHaveBeenCalledTimes(3); + cancel(); + }); + + it('stops after being canceled', async () => { + const task = jest.fn(async () => {}); + const cancel = runPeriodically(task, 1000); + await flushPromises(); + cancel(); + await advanceTimersByTime(1000); + await advanceTimersByTime(1000); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('continues running after failures', async () => { + const task = jest.fn(async () => { + throw new Error(); + }); + const cancel = runPeriodically(task, 1000); + await flushPromises(); + await advanceTimersByTime(1000); + await advanceTimersByTime(1000); + expect(task).toHaveBeenCalledTimes(3); + cancel(); + }); + + it('waits till a long running task is completed', async () => { + const task = jest.fn( + () => new Promise(resolve => setTimeout(resolve, 10000)), + ); + const cancel = runPeriodically(task, 1000); + await flushPromises(); + await advanceTimersByTime(1000); + expect(task).toHaveBeenCalledTimes(1); + await advanceTimersByTime(9000); + await advanceTimersByTime(1000); + expect(task).toHaveBeenCalledTimes(2); + cancel(); + }); +}); diff --git a/plugins/search-backend-node/src/runPeriodically.ts b/plugins/search-backend-node/src/runPeriodically.ts new file mode 100644 index 0000000000..d5f5f87d7f --- /dev/null +++ b/plugins/search-backend-node/src/runPeriodically.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Runs a function repeatedly, with a fixed wait between invocations. + * + * Supports async functions, and silently ignores exceptions and rejections. + * + * @param fn The function to run. May return a Promise. + * @param delayMs The delay between a completed function invocation and the + * next. + * @returns A function that, when called, stops the invocation loop. + */ +export function runPeriodically(fn: () => any, delayMs: number): () => void { + let cancel: () => void; + let cancelled = false; + const cancellationPromise = new Promise(resolve => { + cancel = () => { + resolve(); + cancelled = true; + }; + }); + + const startRefresh = async () => { + while (!cancelled) { + try { + await fn(); + } catch { + // ignore intentionally + } + + await Promise.race([ + new Promise(resolve => setTimeout(resolve, delayMs)), + cancellationPromise, + ]); + } + }; + startRefresh(); + + return cancel!; +} diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index 9e617b73ed..7eba677439 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -5,7 +5,7 @@ ```ts import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; // @public (undocumented) diff --git a/plugins/search/package.json b/plugins/search/package.json index 0cf27e8409..fba0b2ab3b 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/search-common": "^0.1.2", @@ -48,8 +48,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index c69c233a23..e577cf385d 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -47,8 +47,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index 8c255c39a4..4a0b8f3eea 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -36,8 +36,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/shortcuts/src/Shortcuts.tsx b/plugins/shortcuts/src/Shortcuts.tsx index 775b0b3809..69ab6a48ac 100644 --- a/plugins/shortcuts/src/Shortcuts.tsx +++ b/plugins/shortcuts/src/Shortcuts.tsx @@ -16,24 +16,19 @@ import React, { useMemo } from 'react'; import { useObservable } from 'react-use'; -import { makeStyles } from '@material-ui/core'; import PlayListAddIcon from '@material-ui/icons/PlaylistAdd'; import { ShortcutItem } from './ShortcutItem'; import { AddShortcut } from './AddShortcut'; import { shortcutsApiRef } from './api'; -import { Progress, SidebarItem } from '@backstage/core-components'; +import { + Progress, + SidebarItem, + SidebarScrollWrapper, +} from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; -const useStyles = makeStyles({ - root: { - flex: '1 1 auto', - overflowY: 'scroll', - }, -}); - export const Shortcuts = () => { - const classes = useStyles(); const shortcutApi = useApi(shortcutsApiRef); const shortcuts = useObservable( useMemo(() => shortcutApi.shortcut$(), [shortcutApi]), @@ -50,7 +45,7 @@ export const Shortcuts = () => { }; return ( -
+ { /> )) )} -
+ ); }; diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index b83fcf1669..c71b3e9177 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -48,8 +48,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index d1bfbfa9b8..6fbbba61f3 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.4", "@backstage/theme": "^0.2.8", @@ -46,8 +46,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 5b9465a109..20602282c6 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-tech-radar +## 0.4.2 + +### Patch Changes + +- 846168da2: Add optional id prop passing it to the load API +- Updated dependencies + - @backstage/core-components@0.1.4 + ## 0.4.1 ### Patch Changes diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index 35c510ddd3..469a12e8e4 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -82,7 +82,9 @@ import { } from '@backstage/plugin-tech-radar'; class MyOwnClient implements TechRadarApi { - async load(): Promise { + async load(id: string | undefined): Promise { + // if needed id prop can be used to fetch the correct data + const data = await fetch('https://mydata.json').then(res => res.json()); // maybe you'll need to do some data transformation here to make it look like TechRadarLoaderResponse @@ -120,3 +122,7 @@ You can use the `svgProps` option to pass custom React props to the `` elem // const { getByTestId } = render(...); // expect(getByTestId('tech-radar-svg')).toBeInTheDocument(); ``` + +### How do I support multiple radars + +The `TechRadarPage` and `TechRadarComponent` components both take an optional `id` prop which is subsequently passed to the `load` method of the API to distinguish which radar's data to load. diff --git a/plugins/tech-radar/api-report.md b/plugins/tech-radar/api-report.md index f8bcc84d3a..8b98f1e494 100644 --- a/plugins/tech-radar/api-report.md +++ b/plugins/tech-radar/api-report.md @@ -46,7 +46,7 @@ export interface RadarQuadrant { name: string; } -// @public +// @public (undocumented) export interface RadarRing { // (undocumented) color: string; @@ -68,8 +68,7 @@ export const Router: { // @public (undocumented) export interface TechRadarApi { - // (undocumented) - load: () => Promise; + load: (id: string | undefined) => Promise; } // @public (undocumented) @@ -78,17 +77,19 @@ export const techRadarApiRef: ApiRef; // @public (undocumented) export const TechRadarComponent: (props: TechRadarComponentProps) => JSX.Element; -// @public +// @public (undocumented) export interface TechRadarComponentProps { // (undocumented) height: number; // (undocumented) + id?: string; + // (undocumented) svgProps?: object; // (undocumented) width: number; } -// @public +// @public (undocumented) export interface TechRadarLoaderResponse { // (undocumented) entries: RadarEntry[]; diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index e4dd9de462..3ad8d3020f 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.4.1", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -44,8 +44,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index fe574fcfd1..34307a1697 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -23,11 +23,14 @@ export const techRadarApiRef = createApiRef({ }); export interface TechRadarApi { - // Loads the TechRadar response data to pass through to the TechRadar component - load: () => Promise; + /** + * Loads the TechRadar response data to pass through to the TechRadar component. + * Takes the id prop of the TechRadarComponent or TechRadarPage to distinguish between multiple radars if needed + */ + load: (id: string | undefined) => Promise; } -/** +/* * Types related to the Radar's visualization. */ @@ -59,7 +62,7 @@ export interface RadarEntrySnapshot { moved?: MovedState; } -/** +/* * Types related to data collection for the Radar. */ @@ -69,11 +72,12 @@ export interface TechRadarLoaderResponse { entries: RadarEntry[]; } -/** +/* * Set up the Radar as a Backstage component. */ export interface TechRadarComponentProps { + id?: string; width: number; height: number; svgProps?: object; diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index fe42ad8e78..faa2763f17 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -27,13 +27,14 @@ import { Entry } from '../utils/types'; import { Progress } from '@backstage/core-components'; import { useApi, errorApiRef } from '@backstage/core-plugin-api'; -const useTechRadarLoader = () => { +const useTechRadarLoader = (id: string | undefined) => { const errorApi = useApi(errorApiRef); const techRadarApi = useApi(techRadarApiRef); - const { error, value, loading } = useAsync(async () => techRadarApi.load(), [ - techRadarApi, - ]); + const { error, value, loading } = useAsync( + async () => techRadarApi.load(id), + [techRadarApi], + ); useEffect(() => { if (error) { @@ -45,7 +46,7 @@ const useTechRadarLoader = () => { }; const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { - const { loading, error, value: data } = useTechRadarLoader(); + const { loading, error, value: data } = useTechRadarLoader(props.id); const mapToEntries = ( loaderResponse: TechRadarLoaderResponse | undefined, diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index f4b768d369..7215fc40df 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -85,6 +85,7 @@ describe('RadarPage', () => { height: 800, svgProps: { 'data-testid': 'tech-radar-svg' }, }; + jest.spyOn(mockClient, 'load'); const { getByText, getByTestId } = await renderInTestApp( @@ -100,6 +101,30 @@ describe('RadarPage', () => { getByText('Pick the recommended technologies for your projects'), ).toBeInTheDocument(); expect(getByTestId('tech-radar-svg')).toBeInTheDocument(); + expect(mockClient.load).toBeCalledWith(undefined); + }); + + it('should call load with id', async () => { + const techRadarProps = { + width: 1200, + height: 800, + svgProps: { 'data-testid': 'tech-radar-svg' }, + id: 'myId', + }; + jest.spyOn(mockClient, 'load'); + + const { getByTestId } = await renderInTestApp( + + + + + , + ); + + await waitForElement(() => getByTestId('tech-radar-svg')); + + expect(getByTestId('tech-radar-svg')).toBeInTheDocument(); + expect(mockClient.load).toBeCalledWith('myId'); }); it('should call the errorApi if load fails', async () => { diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index d9992c8467..5f63c1b31d 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-techdocs-backend +## 0.8.5 + +### Patch Changes + +- b47fc34bc: Update "service catalog" references to "software catalog" +- Updated dependencies + - @backstage/backend-common@0.8.4 + - @backstage/techdocs-common@0.6.6 + ## 0.8.4 ### Patch Changes diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index d433d428e0..947439533a 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -8,7 +8,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import { GeneratorBuilder } from '@backstage/techdocs-common'; import { Knex } from 'knex'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PreparerBuilder } from '@backstage/techdocs-common'; import { PublisherBase } from '@backstage/techdocs-common'; diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 2dbfb17b23..0f0752a0c2 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.8.4", + "version": "0.8.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,22 +30,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.8.3", + "@backstage/backend-common": "^0.8.4", "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/techdocs-common": "^0.6.5", + "@backstage/techdocs-common": "^0.6.6", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "fs-extra": "^9.0.1", + "fs-extra": "^10.0.0", "knex": "^0.95.1", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.2", + "@backstage/cli": "^0.7.3", "@types/dockerode": "^3.2.1", "supertest": "^6.1.3" }, diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 94ae68a560..9e93c3a251 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-techdocs +## 0.9.8 + +### Patch Changes + +- 99a2873c7: Include cookies when making fetch requests for SVG from techdocs plugin +- a444c7431: Filter fetched entity fields to optimize loading techdocs list +- Updated dependencies + - @backstage/plugin-catalog-react@0.2.5 + - @backstage/core-components@0.1.4 + - @backstage/integration@0.5.7 + ## 0.9.7 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 2f15ffe3db..1a5abc6fbf 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.9.7", + "version": "0.9.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,12 +33,12 @@ "dependencies": { "@backstage/catalog-model": "^0.8.4", "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.6", + "@backstage/integration": "^0.5.7", "@backstage/integration-react": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.2.4", + "@backstage/plugin-catalog-react": "^0.2.5", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -52,8 +52,8 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx index 9b106c94ce..90b968de6c 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -124,7 +124,9 @@ export const TechDocsCustomHome = ({ const configApi: ConfigApi = useApi(configApiRef); const { value: entities, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities(); + const response = await catalogApi.getEntities({ + fields: ['apiVersion', 'kind', 'metadata', 'spec.owner', 'spec.type'], + }); return response.items.filter((entity: Entity) => { return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; }); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 84ac4be7ee..f04c9963f0 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -154,6 +154,9 @@ describe('addBaseUrl', () => { await waitFor(() => { const actualSrc = root.getElementById('x')?.getAttribute('src'); expect(expectedSrc).toEqual(actualSrc); + expect(global.fetch).toHaveBeenCalledWith(`${API_ORIGIN_URL}/test.svg`, { + credentials: 'include', + }); }); }); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 81021fbde8..f21cb8c47a 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -64,7 +64,7 @@ export const addBaseUrl = ({ const apiOrigin = await techdocsStorageApi.getApiOrigin(); if (isSvgNeedingInlining(attributeName, elemAttribute, apiOrigin)) { try { - const svg = await fetch(newValue); + const svg = await fetch(newValue, { credentials: 'include' }); const svgContent = await svg.text(); elem.setAttribute( attributeName, diff --git a/plugins/todo-backend/api-report.md b/plugins/todo-backend/api-report.md index 8bcfe30aee..d203a52b36 100644 --- a/plugins/todo-backend/api-report.md +++ b/plugins/todo-backend/api-report.md @@ -8,7 +8,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; import { UrlReader } from '@backstage/backend-common'; diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 4c86b5b524..d4a18a07b9 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.8.4", - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.2.4", @@ -40,8 +40,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", @@ -49,8 +49,8 @@ "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "msw": "^0.29.0", - "cross-fetch": "^3.0.6" + "cross-fetch": "^3.0.6", + "msw": "^0.29.0" }, "files": [ "dist" diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index e4720dec08..7a691594a4 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -42,8 +42,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index b99897b4bf..311498ba49 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core-components": "^0.1.3", + "@backstage/core-components": "^0.1.4", "@backstage/core-plugin-api": "^0.1.3", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", @@ -42,8 +42,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.2", - "@backstage/core-app-api": "^0.1.3", + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", "@backstage/dev-utils": "^0.2.0", "@backstage/test-utils": "^0.1.14", "@testing-library/jest-dom": "^5.10.1", diff --git a/yarn.lock b/yarn.lock index 2a6827419c..4fc278e1a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11,10 +11,10 @@ call-me-maybe "^1.0.1" js-yaml "^3.13.1" -"@apollo/protobufjs@^1.0.3": - version "1.0.4" - resolved "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.0.4.tgz#cf01747a55359066341f31b5ce8db17df44244e0" - integrity sha512-EE3zx+/D/wur/JiLp6VCiw1iYdyy1lCJMf8CGPkLeDt5QJrN4N8tKFx33Ah4V30AUQzMk7Uz4IXKZ1LOj124gA== +"@apollo/protobufjs@1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.2.tgz#4bd92cd7701ccaef6d517cdb75af2755f049f87c" + integrity sha512-vF+zxhPiLtkwxONs6YanSt1EpwpGilThpneExUN5K3tCymuxNnVq2yojTvnpRjv2QfsEIt/n7ozPIIzBLwGIDQ== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2" @@ -30,19 +30,17 @@ "@types/node" "^10.1.0" long "^4.0.0" -"@apollographql/apollo-tools@^0.4.3": - version "0.4.8" - resolved "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.4.8.tgz#d81da89ee880c2345eb86bddb92b35291f6135ed" - integrity sha512-W2+HB8Y7ifowcf3YyPHgDI05izyRtOeZ4MqIr7LbTArtmJ0ZHULWpn84SGMW7NAvTV1tFExpHlveHhnXuJfuGA== - dependencies: - apollo-env "^0.6.5" +"@apollographql/apollo-tools@^0.5.0": + version "0.5.1" + resolved "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.5.1.tgz#f0baef739ff7e2fafcb8b98ad29f6ac817e53e32" + integrity sha512-ZII+/xUFfb9ezDU2gad114+zScxVFMVlZ91f8fGApMzlS1kkqoyLnC4AJaQ1Ya/X+b63I20B4Gd+eCL8QuB4sA== -"@apollographql/graphql-playground-html@1.6.26": - version "1.6.26" - resolved "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.26.tgz#2f7b610392e2a872722912fc342b43cf8d641cb3" - integrity sha512-XAwXOIab51QyhBxnxySdK3nuMEUohhDsHQ5Rbco/V1vjlP75zZ0ZLHD9dTpXTN8uxKxopb2lUvJTq+M4g2Q0HQ== +"@apollographql/graphql-playground-html@1.6.27": + version "1.6.27" + resolved "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.27.tgz#bc9ab60e9445aa2a8813b4e94f152fa72b756335" + integrity sha512-tea2LweZvn6y6xFV11K0KC8ETjmm52mQrW+ezgB2O/aTQf8JGyFmMcRPFgUaQZeHbWdm8iisDC6EjOKsXu0nfw== dependencies: - xss "^1.0.6" + xss "^1.0.8" "@apollographql/graphql-upload-8-fork@^8.1.3": version "8.1.3" @@ -1285,14 +1283,6 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime-corejs2@^7.8.7": - version "7.14.0" - resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.14.0.tgz#5519b92ccc819bd3d0a1ecb25ab3cb5a775485f8" - integrity sha512-btR4E8JiGlmmDI5YgirlG9z3T91rBdxnVh2YuEStrHDcekffaaIeK+CE0S4IaYUyYhMa7rFDfF2GEO79XNbLEA== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - "@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.11.2", "@babel/runtime-corejs3@^7.13.17": version "7.14.0" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.14.0.tgz#6bf5fbc0b961f8e3202888cb2cd0fb7a0a9a3f66" @@ -1370,32 +1360,6 @@ "@babel/helper-validator-identifier" "^7.14.0" to-fast-properties "^2.0.0" -"@backstage/catalog-model@^0.7.4": - version "0.8.4" - dependencies: - "@backstage/config" "^0.1.5" - "@backstage/errors" "^0.1.1" - "@types/json-schema" "^7.0.5" - "@types/yup" "^0.29.8" - ajv "^7.0.3" - json-schema "^0.3.0" - lodash "^4.17.15" - uuid "^8.0.0" - yup "^0.29.3" - -"@backstage/catalog-model@^0.7.9": - version "0.8.4" - dependencies: - "@backstage/config" "^0.1.5" - "@backstage/errors" "^0.1.1" - "@types/json-schema" "^7.0.5" - "@types/yup" "^0.29.8" - ajv "^7.0.3" - json-schema "^0.3.0" - lodash "^4.17.15" - uuid "^8.0.0" - yup "^0.29.3" - "@backstage/core-api@^0.2.23": version "0.2.23" resolved "https://registry.npmjs.org/@backstage/core-api/-/core-api-0.2.23.tgz#c0ec13407ff7c78d376eb18e9d8e1490d54d995b" @@ -1414,7 +1378,7 @@ react-use "^17.2.4" zen-observable "^0.8.15" -"@backstage/core@*", "@backstage/core@^0.7.11", "@backstage/core@^0.7.3", "@backstage/core@^0.7.9": +"@backstage/core@*": version "0.7.14" resolved "https://registry.npmjs.org/@backstage/core/-/core-0.7.14.tgz#863844fe40bb6a29bcc2d297e42055633b0e886f" integrity sha512-W7EMspBXrp1GPALK6+qdJjsvkqcaYGFyoh8/bRAXABIkJpGQGiy4xUZUKDoMhd+OdVHv/mzyyv3fH2yc32J07w== @@ -1460,48 +1424,6 @@ remark-gfm "^1.0.0" zen-observable "^0.8.15" -"@backstage/plugin-catalog-react@^0.1.4": - version "0.1.6" - resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.1.6.tgz#1d72756cce22b880987c30ac27e23af8efa22843" - integrity sha512-/60GIrcFfyAiKn3bylXegwtHf1NQxQa4PcXD7nSIB5ISCuot2mkI32LMjciEvNHR7efGA5Yv6yWelFSFdvbcKQ== - dependencies: - "@backstage/catalog-client" "^0.3.11" - "@backstage/catalog-model" "^0.7.9" - "@backstage/core" "^0.7.9" - "@material-ui/core" "^4.11.0" - "@types/react" "^16.9" - lodash "^4.17.15" - react "^16.13.1" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - react-use "^17.2.4" - -"@backstage/plugin-catalog@^0.5.1": - version "0.6.4" - dependencies: - "@backstage/catalog-client" "^0.3.14" - "@backstage/catalog-model" "^0.8.4" - "@backstage/core-components" "^0.1.3" - "@backstage/core-plugin-api" "^0.1.3" - "@backstage/errors" "^0.1.1" - "@backstage/integration" "^0.5.6" - "@backstage/integration-react" "^0.1.4" - "@backstage/plugin-catalog-react" "^0.2.4" - "@backstage/theme" "^0.2.8" - "@material-ui/core" "^4.11.0" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.45" - "@types/react" "^16.9" - classnames "^2.2.6" - git-url-parse "^11.4.4" - lodash "^4.17.21" - react "^16.13.1" - react-dom "^16.13.1" - react-helmet "6.1.0" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - react-use "^17.2.4" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1767,6 +1689,11 @@ resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.7.tgz#0fe1fa0ef02c827919e23c2802a4b25589ac522d" integrity sha512-EG/1qDiQvd12RoNJ6H+sZcHVswC/3uMx/ySvfaJ24vB30rLjkgHggEXbgMbfgki7wMuiQ/zXI8QlmF1k3kWRGQ== +"@date-io/core@^2.10.11": + version "2.10.11" + resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.11.tgz#b1a3d57730f3eaaab54d5658be4a71727297e357" + integrity sha512-keXQnwH0LM8wyvu+j5Z2KGK56D+eItjy7DnwuWl/oV+DM2UEYl0z5WhdPMpfswSyt/kjuPOzcVF/7u/skMLaoA== + "@date-io/date-fns@^1.1.0": version "1.3.13" resolved "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz#7798844041640ab393f7e21a7769a65d672f4735" @@ -1774,12 +1701,12 @@ dependencies: "@date-io/core" "^1.3.13" -"@date-io/luxon@1.x": - version "1.3.13" - resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a" - integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw== +"@date-io/luxon@2.x": + version "2.10.11" + resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.10.11.tgz#d0981b9fdf5e5f17f8ce59265a3ac6c335565fac" + integrity sha512-SS6SIkp0Y9GFwpQycCTUAyW3OZTW05CWI1DJu10hUzcg8SmjJfhjs7hQY3TOeW+JT6VtXGTVGwbWPUBJsNkhZg== dependencies: - "@date-io/core" "^1.3.13" + "@date-io/core" "^2.10.11" "@emotion/cache@^10.0.27": version "10.0.29" @@ -2088,9 +2015,9 @@ subscriptions-transport-ws "^0.9.18" "@graphql-codegen/cli@^1.21.3": - version "1.21.5" - resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.5.tgz#b9041553747cfb2dee7c3473a2e2461ec3e7ada5" - integrity sha512-w3SovNJ9qtMhFLAdPZeCdGvHXDgfdb53mueWDTyncOt04m+tohVnY4qExvyKLTN5zlGxrA/5ubp2x8Az0xQarA== + version "1.21.6" + resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.6.tgz#d06b5f6cb625541f3981d69f99966e520b958072" + integrity sha512-wtBk4lk/YxG6MrxnBOxE9nCfR9PNDjaqA8CF9hi6Q8jiSl4sV03tC2R+gE7+2EI8J6Xa1bxZe15LnBhVwb/mUA== dependencies: "@graphql-codegen/core" "1.17.10" "@graphql-codegen/plugin-helpers" "^1.18.7" @@ -2107,7 +2034,7 @@ ansi-escapes "^4.3.1" chalk "^4.1.0" change-case-all "1.0.14" - chokidar "^3.5.1" + chokidar "^3.5.2" common-tags "^1.8.0" cosmiconfig "^7.0.0" debounce "^1.2.0" @@ -2126,7 +2053,7 @@ mkdirp "^1.0.4" string-env-interpolation "^1.0.1" ts-log "^2.2.3" - tslib "~2.2.0" + tslib "~2.3.0" valid-url "^1.0.9" wrap-ansi "^7.0.0" yaml "^1.10.0" @@ -2754,6 +2681,11 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@josephg/resolvable@^1.0.0": + version "1.0.1" + resolved "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz#69bc4db754d79e1a2f17a650d3466e038d94a5eb" + integrity sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg== + "@jsdevtools/ono@^7.1.3": version "7.1.3" resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" @@ -3496,7 +3428,7 @@ resolved "https://registry.npmjs.org/@material-icons/font/-/font-1.0.3.tgz#f722e5a69a03f20ef47d015cb69420bebeeaabe5" integrity sha512-aIRd0Z9b/HJ/O24KOaP7dNsXypMnXhpWrpLVYvQB/JesVqXYSbioYND200sR+C14a0LSCp+qWnWCnSXmN1hWGw== -"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1": +"@material-ui/core@^4.11.0": version "4.11.3" resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.11.3.tgz#f22e41775b0bd075e36a7a093d43951bf7f63850" integrity sha512-Adt40rGW6Uds+cAyk3pVgcErpzU/qxc7KBR94jFHBYretU4AtWZltYcNsbeMn9tXL86jjVL1kuGcIHsgLgFGRw== @@ -3514,7 +3446,25 @@ react-is "^16.8.0 || ^17.0.0" react-transition-group "^4.4.0" -"@material-ui/icons@^4.9.1": +"@material-ui/core@^4.11.3": + version "4.11.4" + resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.11.4.tgz#4fb9fe5dec5dcf780b687e3a40cff78b2b9640a4" + integrity sha512-oqb+lJ2Dl9HXI9orc6/aN8ZIAMkeThufA5iZELf2LQeBn2NtjVilF5D2w7e9RpntAzDb4jK5DsVhkfOvFY/8fg== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/styles" "^4.11.4" + "@material-ui/system" "^4.11.3" + "@material-ui/types" "5.1.0" + "@material-ui/utils" "^4.11.2" + "@types/react-transition-group" "^4.2.0" + clsx "^1.0.4" + hoist-non-react-statics "^3.3.2" + popper.js "1.16.1-lts" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + react-transition-group "^4.4.0" + +"@material-ui/icons@^4.11.2", "@material-ui/icons@^4.9.1": version "4.11.2" resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== @@ -3532,6 +3482,17 @@ prop-types "^15.7.2" react-is "^16.8.0" +"@material-ui/lab@4.0.0-alpha.57": + version "4.0.0-alpha.57" + resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" + integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.11.2" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@material-ui/pickers@^3.2.2": version "3.2.10" resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz#19df024895876eb0ec7cd239bbaea595f703f0ae" @@ -3556,7 +3517,7 @@ react-transition-group "^4.0.0" rifm "^0.7.0" -"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.9.6": +"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.11.4", "@material-ui/styles@^4.9.6": version "4.11.4" resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz#eb9dfccfcc2d208243d986457dff025497afa00d" integrity sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew== @@ -4116,40 +4077,40 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rjsf/core@^2.4.0": - version "2.5.1" - resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.5.1.tgz#95a842d22bab5f83929662fcd73739108e9f5cbb" - integrity sha512-km8NYScXNONaL5BiSLS6wyDj49pOLZtn0iXg7Zxlm921uuf3o2AAX5SuZS5kB4Zj2zlrVMrXESexfX6bxdDYHw== +"@rjsf/core@^3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@rjsf/core/-/core-3.0.0.tgz#7f79b0635c969e3fbfe0aaa4773d844fbee68a63" + integrity sha512-QSvyVMDiwd7neVnIMOte4tZUvtDWgLDYKOsq1tgCsIFh+RXskd3AEBf6IsK8w6Wg36Om7YalDqqrtXbk9z+rLw== dependencies: - "@babel/runtime-corejs2" "^7.8.7" - "@types/json-schema" "^7.0.4" + "@types/json-schema" "^7.0.7" ajv "^6.7.0" - core-js "^2.5.7" + core-js-pure "^3.6.5" json-schema-merge-allof "^0.6.0" jsonpointer "^4.0.1" lodash "^4.17.15" + nanoid "^3.1.23" prop-types "^15.7.2" - react-app-polyfill "^1.0.4" react-is "^16.9.0" - shortid "^2.2.14" -"@rjsf/material-ui@^2.4.0": - version "2.5.1" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.5.1.tgz#b93ac9f1f4a909e2aae729616859c2d72557e53e" - integrity sha512-ooKxQJO12+i1xCGtknMZDxWSbVlSEgQ5U1I7lJ+uI/MvwAg3Rz9wb2cTEF3ErBczT7EEW+j1lR19rxBjFd78MA== +"@rjsf/material-ui@^3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-3.0.0.tgz#69ece2cb549f0e860b5f89898db90edcc95b15ba" + integrity sha512-T2B8QnrDQphbFNxDz7baAa0zTd5TXJmO9soHBPTKKdniRbMEOQ19AJBbZkA3ED2XZa/xrUY/6XjERQLpNACddw== -"@roadiehq/backstage-plugin-buildkite@^1.0.3": - version "1.0.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-buildkite/-/backstage-plugin-buildkite-1.0.3.tgz#d865da83380e1cdcec5825f856597446f5652f53" - integrity sha512-HMDworpWRgwhmQETAe2DUX3cxddP+N1q8bTWxAQwOHO6mecF08uvZDGA1VdWabXSPpJwFUiZEi9OLlNYkl32GA== +"@roadiehq/backstage-plugin-buildkite@^1.0.4": + version "1.0.4" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-buildkite/-/backstage-plugin-buildkite-1.0.4.tgz#8bd89d235c12b0093dd8c35d5397d8332bb62df0" + integrity sha512-SkPJzkLhVo1vMM76EShJRRQegXFKkY1bf1TMVIqR2Cm3E9QXHyN099+dSimFW8f5uyNioEHuRkSh7Iwm7AZekA== dependencies: - "@backstage/catalog-model" "^0.8.0" - "@backstage/core" "^0.7.11" - "@backstage/plugin-catalog-react" "^0.2.0" - "@backstage/theme" "^0.2.7" - "@material-ui/core" "^4.11.0" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.45" + "@backstage/catalog-model" "^0.8.4" + "@backstage/core-app-api" "^0.1.3" + "@backstage/core-components" "^0.1.3" + "@backstage/core-plugin-api" "^0.1.3" + "@backstage/plugin-catalog-react" "^0.2.4" + "@backstage/theme" "^0.2.6" + "@material-ui/core" "^4.11.3" + "@material-ui/icons" "^4.11.2" + "@material-ui/lab" "4.0.0-alpha.57" history "^5.0.0" moment "^2.29.1" react "^16.13.1" @@ -4159,13 +4120,15 @@ react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -"@roadiehq/backstage-plugin-github-insights@^1.1.11": - version "1.1.11" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-1.1.11.tgz#6f716a0864b7e3a7255d7ec97c92a68a99874025" - integrity sha512-fDW92aCGXJtZ36TLgEkHiLCZgNKh5lqRFSmStd+58o4QRMiJp852Tcbi2ySrtf03SnGKn7rvD2CqThx/VcwPwg== +"@roadiehq/backstage-plugin-github-insights@^1.1.15": + version "1.1.15" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-1.1.15.tgz#ef033f85c54f5203a2bc161290f46e861dc6feca" + integrity sha512-UH8lvDZd1OVP6fX+mF0eTrxqo/QmGWiCWvxumxkyU61sF3XkXzEqhQDKlWWEtpGLPa++yf98p00wfSeXRfjRWA== dependencies: - "@backstage/catalog-model" "^0.8.0" - "@backstage/core" "^0.7.11" + "@backstage/catalog-model" "^0.8.4" + "@backstage/core-app-api" "^0.1.3" + "@backstage/core-components" "^0.1.3" + "@backstage/core-plugin-api" "^0.1.3" "@backstage/plugin-catalog-react" "^0.2.0" "@backstage/theme" "^0.2.7" "@date-io/core" "2.10.7" @@ -4175,18 +4138,20 @@ "@octokit/rest" "^18.5.3" "@octokit/types" "^6.14.2" history "^5.0.0" - react "^16.13.1" - react-dom "^16.13.1" + react "^16.12.0" + react-dom "^16.12.0" react-router "^6.0.0-beta.0" react-use "^17.2.4" -"@roadiehq/backstage-plugin-github-pull-requests@^1.0.5": - version "1.0.5" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-1.0.5.tgz#7b71823b1c39dbcb89276a5725815c15a0e27943" - integrity sha512-be+buEb4tkMfVB1qFn3OwFc4pq9/Nu+5C5LsdEwhRnJHN7eLcOBMnhH5l0zAYkHErpF3zbI3xngbap+TATbm0Q== +"@roadiehq/backstage-plugin-github-pull-requests@^1.0.8": + version "1.0.8" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-1.0.8.tgz#7d6202ec60808156058bd2d335f7df332934ae56" + integrity sha512-g1CudaKX1NYW0wfbtjcZxFWmDMfw1IvpJD4ymmwZmsogBOHFknlowVmwwSL4W4LwK6IPypIZkOe95Vd2PrjO+Q== dependencies: - "@backstage/catalog-model" "^0.8.0" - "@backstage/core" "^0.7.11" + "@backstage/catalog-model" "^0.8.4" + "@backstage/core-app-api" "^0.1.3" + "@backstage/core-components" "^0.1.3" + "@backstage/core-plugin-api" "^0.1.3" "@backstage/plugin-catalog-react" "^0.2.0" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -4196,25 +4161,26 @@ history "^5.0.0" moment "^2.27.0" node-fetch "^2.6.1" - react "^16.13.1" - react-dom "^16.13.1" + react "^16.12.0" + react-dom "^16.12.0" react-router "6.0.0-beta.0" react-use "^17.2.4" -"@roadiehq/backstage-plugin-travis-ci@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-1.0.0.tgz#5aa29f4ba2086b6341a903a22c843d898808f503" - integrity sha512-JjMDwbou1iY0sSoit8ttpzXEP6lhmLmxf/Pj6Am/1rcia5KLdeMUqpilkzQ33LeHiMXXxu95gp4r6aJ5vC9wGg== +"@roadiehq/backstage-plugin-travis-ci@^1.0.4": + version "1.0.4" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-1.0.4.tgz#224d17749b6bb33fbc5bf50489d7e7a87b06aa3a" + integrity sha512-rSMB+ZuI9xXIwgqJv7NW/wt3ulZa9rSLsCzkMO30/6/TMbenmsIyfmIP9XI32D9mPew4dVX9spi6KsgEo41WHw== dependencies: - "@backstage/catalog-model" "^0.7.4" - "@backstage/core" "^0.7.3" - "@backstage/plugin-catalog" "^0.5.1" - "@backstage/plugin-catalog-react" "^0.1.4" - "@backstage/theme" "^0.2.5" - "@material-ui/core" "^4.9.1" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.45" - cross-fetch "^3.0.6" + "@backstage/catalog-model" "^0.8.4" + "@backstage/core-app-api" "^0.1.3" + "@backstage/core-components" "^0.1.3" + "@backstage/core-plugin-api" "^0.1.3" + "@backstage/plugin-catalog-react" "^0.2.4" + "@backstage/theme" "^0.2.6" + "@material-ui/core" "^4.11.3" + "@material-ui/icons" "^4.11.2" + "@material-ui/lab" "4.0.0-alpha.57" + cross-fetch "^3.1.3" date-fns "^2.18.0" history "^5.0.0" moment "^2.29.1" @@ -4222,7 +4188,7 @@ react-dom "^16.13.1" react-router "6.0.0-beta.0" react-router-dom "6.0.0-beta.0" - react-use "^15.3.3" + react-use "^17.2.4" "@rollup/plugin-commonjs@^17.1.0": version "17.1.0" @@ -4256,16 +4222,16 @@ is-module "^1.0.0" resolve "^1.19.0" -"@rollup/plugin-yaml@^2.1.1": - version "2.1.1" - resolved "https://registry.npmjs.org/@rollup/plugin-yaml/-/plugin-yaml-2.1.1.tgz#6c864adee22004eb325d53bc4ee5553828e056d8" - integrity sha512-CnGD3dbDhP+JeKFX7kJuaBOa7jVzXpl3G8lXp1hasB87cDYOz7qNWd4cFPmYbxMsT/8OCVsceIpRtQ+cj9uxBw== +"@rollup/plugin-yaml@^3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-yaml/-/plugin-yaml-3.0.0.tgz#632af397a6faaea10c885680c575ae01f4556c1d" + integrity sha512-nXYI3WsfG7Oq5K5k7evGQ1ZABGCPhHxMaMX4T3GRSDi9VyhkaU1+8R1RzTRFXITFJHx0QEGn8jR+P9pux5FuBQ== dependencies: - "@rollup/pluginutils" "^3.0.1" - js-yaml "^3.13.1" + "@rollup/pluginutils" "^3.1.0" + js-yaml "^3.14.0" tosource "^1.0.0" -"@rollup/pluginutils@^3.0.1", "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": +"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== @@ -5443,6 +5409,26 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tsconfig/node10@^1.0.7": + version "1.0.8" + resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" + integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg== + +"@tsconfig/node12@^1.0.7": + version "1.0.9" + resolved "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c" + integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw== + +"@tsconfig/node14@^1.0.0": + version "1.0.1" + resolved "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2" + integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg== + +"@tsconfig/node16@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.1.tgz#a6ca6a9a0ff366af433f42f5f0e124794ff6b8f1" + integrity sha512-FTgBI767POY/lKNDNbIzgAX6miIDBs6NTCbdlDb8TrWovHsSvaVIZDlTqym29C6UqhzwcJx4CYr+AlrMywA0cA== + "@types/accepts@*", "@types/accepts@^1.3.5": version "1.3.5" resolved "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" @@ -5456,9 +5442,9 @@ integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== "@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== + version "5.3.0" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f" + integrity sha512-qJ79qsmq7O/k9FYwsF6O1xVA1PeLV+9Bh3TYkVCu3VzMR6vN9JQkgEOh/rrQ0R+F4Ta+R3thHGewxQtFglwVfg== dependencies: "@types/glob" "*" @@ -5473,9 +5459,9 @@ integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== "@types/aws4@^1.5.1": - version "1.5.1" - resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9" - integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk= + version "1.5.2" + resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.2.tgz#34e35b4405a619b9205be3e7678963bc7c8a47db" + integrity sha512-r8+XOv0BKw3Br0oU6w9cfu21PaQq/5ZeXvMOivNQYJLc9WcPCpEFDaQu72QNEQjYv5Otu48VhjjnbtwrSmk/rg== "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.9" @@ -5668,14 +5654,7 @@ resolved "https://registry.npmjs.org/@types/core-js/-/core-js-2.5.4.tgz#fc42ebde7d9cfa7c5f2668f117449b02348e41fd" integrity sha512-Xwy8o12ak+iYgFr/KCVaVK5Sy+jFMiiPAID3+ObvMlBzy26XQJw5xu+a6rlHsrJENXj/AwJOGsJpVohUjAzSKQ== -"@types/cors@2.8.8": - version "2.8.8" - resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.8.tgz#317a8d8561995c60e35b9e0fcaa8d36660c98092" - integrity sha512-fO3gf3DxU2Trcbr75O7obVndW/X5k8rJNZkLXlQWStTHhP71PkRqjwPIEI0yMnJdg9R9OasjU+Bsr+Hr1xy/0w== - dependencies: - "@types/express" "*" - -"@types/cors@^2.8.6": +"@types/cors@2.8.10", "@types/cors@^2.8.6": version "2.8.10" resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz#61cc8469849e5bcdd0c7044122265c39cec10cf4" integrity sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ== @@ -5771,19 +5750,10 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.19" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d" - integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - -"@types/express-serve-static-core@4.17.18": - version "4.17.18" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" - integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18", "@types/express-serve-static-core@^4.17.21", "@types/express-serve-static-core@^4.17.5": + version "4.17.21" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.21.tgz#a427278e106bca77b83ad85221eae709a3414d42" + integrity sha512-gwCiEZqW6f7EoR8TTEfalyEhb1zA5jQJnRngr97+3pzMaO1RKoI1w2bw07TK72renMUVWcWS5mLI6rk1NqN0nA== dependencies: "@types/node" "*" "@types/qs" "*" @@ -5804,13 +5774,13 @@ "@types/express" "*" "@types/xml2js" "*" -"@types/express@*", "@types/express@4.17.7", "@types/express@^4.17.6", "@types/express@^4.17.7": - version "4.17.7" - resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" - integrity sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ== +"@types/express@*", "@types/express@^4.17.12", "@types/express@^4.17.6", "@types/express@^4.17.7": + version "4.17.12" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.12.tgz#4bc1bf3cd0cfe6d3f6f2853648b40db7d54de350" + integrity sha512-pTYas6FrP15B1Oa0bkN5tQMNqOcVXa9j4FTFtO8DWI9kppKib+6NJtfTOOLcwxuuYvcX2+dVG6et1SxW/Kc17Q== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "*" + "@types/express-serve-static-core" "^4.17.18" "@types/qs" "*" "@types/serve-static" "*" @@ -6010,7 +5980,7 @@ dependencies: "@types/sizzle" "*" -"@types/js-cookie@2.2.6", "@types/js-cookie@^2.2.6": +"@types/js-cookie@^2.2.6": version "2.2.6" resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f" integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== @@ -6169,10 +6139,10 @@ resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73" integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM= -"@types/mime@*": - version "2.0.1" - resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" - integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== +"@types/mime@^1": + version "1.3.2" + resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" + integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== "@types/mini-css-extract-plugin@^1.2.2": version "1.2.2" @@ -6219,14 +6189,6 @@ dependencies: "@types/node" "*" -"@types/node-fetch@2.5.7": - version "2.5.7" - resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" - integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.4", "@types/node-fetch@^2.5.7": version "2.5.8" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb" @@ -6614,12 +6576,12 @@ integrity sha512-+beqKQOh9PYxuHvijhVl+tIHvT6tuwOrE9m14zd+MT2A38KoKZhh7pYJ0SNleLtwDsiIxHDsIk9bv01oOxvSvA== "@types/serve-static@*": - version "1.13.3" - resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" - integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g== + version "1.13.9" + resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" + integrity sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA== dependencies: - "@types/express-serve-static-core" "*" - "@types/mime" "*" + "@types/mime" "^1" + "@types/node" "*" "@types/set-cookie-parser@^2.4.0": version "2.4.0" @@ -6909,9 +6871,9 @@ integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw== "@types/yup@^0.29.8": - version "0.29.11" - resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" - integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== + version "0.29.12" + resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.12.tgz#59c9577bea11d2b3d78717ea7591caacad6dfa1b" + integrity sha512-fA7bXyBzWEAgOwX2SD/5/iaZY/4In0EvJEzFmBWzaGNF4vxr8d5iOFUMFBpL4cMEmlSx2wW9ginJNnoZjE/vOg== "@types/zen-observable@^0.8.0", "@types/zen-observable@^0.8.2": version "0.8.2" @@ -7140,7 +7102,7 @@ dependencies: tslib "^1.9.3" -"@xobotyi/scrollbar-width@1.9.5", "@xobotyi/scrollbar-width@^1.9.5": +"@xobotyi/scrollbar-width@^1.9.5": version "1.9.5" resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ== @@ -7486,7 +7448,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -anymatch@^3.0.3, anymatch@~3.1.1: +anymatch@^3.0.3: version "3.1.1" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== @@ -7494,49 +7456,38 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-cache-control@^0.11.6: - version "0.11.6" - resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.6.tgz#f7bdf924272af47ac474cf3f3f35cfc038cc9485" - integrity sha512-YZ+uuIG+fPy+mkpBS2qKF0v1qlzZ3PW6xZVaDukeK3ed3iAs4L/2YnkTqau3OmoF/VPzX2FmSkocX/OVd59YSw== +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== dependencies: - apollo-server-env "^3.0.0" - apollo-server-plugin-base "^0.10.4" + normalize-path "^3.0.0" + picomatch "^2.0.4" -apollo-datasource@^0.7.3: - version "0.7.3" - resolved "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.3.tgz#c824eb1457bdee5a3173ced0e35e594547e687a0" - integrity sha512-PE0ucdZYjHjUyXrFWRwT02yLcx2DACsZ0jm1Mp/0m/I9nZu/fEkvJxfsryXB6JndpmQO77gQHixf/xGCN976kA== +apollo-cache-control@^0.14.0: + version "0.14.0" + resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.14.0.tgz#95f20c3e03e7994e0d1bd48c59aeaeb575ed0ce7" + integrity sha512-qN4BCq90egQrgNnTRMUHikLZZAprf3gbm8rC5Vwmc6ZdLolQ7bFsa769Hqi6Tq/lS31KLsXBLTOsRbfPHph12w== dependencies: - apollo-server-caching "^0.5.3" - apollo-server-env "^3.0.0" + apollo-server-env "^3.1.0" + apollo-server-plugin-base "^0.13.0" -apollo-env@^0.6.5: - version "0.6.5" - resolved "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.5.tgz#5a36e699d39e2356381f7203493187260fded9f3" - integrity sha512-jeBUVsGymeTHYWp3me0R2CZRZrFeuSZeICZHCeRflHTfnQtlmbSXdy5E0pOyRM9CU4JfQkKDC98S1YglQj7Bzg== +apollo-datasource@^0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.9.0.tgz#b0b2913257a6103a5f4c03cb56d78a30e9d850db" + integrity sha512-y8H99NExU1Sk4TvcaUxTdzfq2SZo6uSj5dyh75XSQvbpH6gdAXIW9MaBcvlNC7n0cVPsidHmOcHOWxJ/pTXGjA== dependencies: - "@types/node-fetch" "2.5.7" - core-js "^3.0.1" - node-fetch "^2.2.0" - sha.js "^2.4.11" + apollo-server-caching "^0.7.0" + apollo-server-env "^3.1.0" -apollo-env@^0.6.6: - version "0.6.6" - resolved "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.6.tgz#d7880805c4e96ee3d4142900a405176a04779438" - integrity sha512-hXI9PjJtzmD34XviBU+4sPMOxnifYrHVmxpjykqI/dUD2G3yTiuRaiQqwRwB2RCdwC1Ug/jBfoQ/NHDTnnjndQ== +apollo-graphql@^0.9.0: + version "0.9.3" + resolved "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.9.3.tgz#1ca6f625322ae10a66f57a39642849a07a7a5dc9" + integrity sha512-rcAl2E841Iko4kSzj4Pt3PRBitmyq1MvoEmpl04TQSpGnoVgl1E/ZXuLBYxMTSnEAm7umn2IsoY+c6Ll9U/10A== dependencies: - "@types/node-fetch" "2.5.7" - core-js "^3.0.1" - node-fetch "^2.2.0" - sha.js "^2.4.11" - -apollo-graphql@^0.6.0: - version "0.6.1" - resolved "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.6.1.tgz#d0bf0aff76f445de3da10e08f6974f1bf65f5753" - integrity sha512-ZRXAV+k+hboCVS+FW86FW/QgnDR7gm/xMUwJPGXEbV53OLGuQQdIT0NCYK7AzzVkCfsbb7NJ3mmEclkZY9uuxQ== - dependencies: - apollo-env "^0.6.6" + core-js-pure "^3.10.2" lodash.sortby "^4.7.0" + sha.js "^2.4.11" apollo-link-http-common@^0.2.14: version "0.2.16" @@ -7557,12 +7508,12 @@ apollo-link@^1.2.12, apollo-link@^1.2.14: tslib "^1.9.3" zen-observable-ts "^0.8.21" -apollo-reporting-protobuf@^0.6.2: - version "0.6.2" - resolved "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.6.2.tgz#5572866be9b77f133916532b10e15fbaa4158304" - integrity sha512-WJTJxLM+MRHNUxt1RTl4zD0HrLdH44F2mDzMweBj1yHL0kSt8I1WwoiF/wiGVSpnG48LZrBegCaOJeuVbJTbtw== +apollo-reporting-protobuf@^0.8.0: + version "0.8.0" + resolved "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.8.0.tgz#ae9d967934d3d8ed816fc85a0d8068ef45c371b9" + integrity sha512-B3XmnkH6Y458iV6OsA7AhfwvTgeZnFq9nPVjbxmLKnvfkEl8hYADtz724uPa0WeBiD7DSFcnLtqg9yGmCkBohg== dependencies: - "@apollo/protobufjs" "^1.0.3" + "@apollo/protobufjs" "1.2.2" apollo-server-caching@0.5.1: version "0.5.1" @@ -7571,95 +7522,95 @@ apollo-server-caching@0.5.1: dependencies: lru-cache "^5.0.0" -apollo-server-caching@^0.5.3: - version "0.5.3" - resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.3.tgz#cf42a77ad09a46290a246810075eaa029b5305e1" - integrity sha512-iMi3087iphDAI0U2iSBE9qtx9kQoMMEWr6w+LwXruBD95ek9DWyj7OeC2U/ngLjRsXM43DoBDXlu7R+uMjahrQ== +apollo-server-caching@^0.7.0: + version "0.7.0" + resolved "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.7.0.tgz#e6d1e68e3bb571cba63a61f60b434fb771c6ff39" + integrity sha512-MsVCuf/2FxuTFVhGLK13B+TZH9tBd2qkyoXKKILIiGcZ5CDUEBO14vIV63aNkMkS1xxvK2U4wBcuuNj/VH2Mkw== dependencies: lru-cache "^6.0.0" -apollo-server-core@^2.21.0: - version "2.21.0" - resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.21.0.tgz#12ee11aee61fa124f11b1d73cae2e068112a3a53" - integrity sha512-GtIiq2F0dVDLzzIuO5+dK/pGq/sGxYlKCqAuQQqzYg0fvZ7fukyluXtcTe0tMI+FJZjU0j0WnKgiLsboCoAlPQ== +apollo-server-core@^2.21.0, apollo-server-core@^2.25.2: + version "2.25.2" + resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.25.2.tgz#ff65da5e512d9b5ca54c8e5e8c78ee28b5987247" + integrity sha512-lrohEjde2TmmDTO7FlOs8x5QQbAS0Sd3/t0TaK2TWaodfzi92QAvIsq321Mol6p6oEqmjm8POIDHW1EuJd7XMA== dependencies: - "@apollographql/apollo-tools" "^0.4.3" - "@apollographql/graphql-playground-html" "1.6.26" + "@apollographql/apollo-tools" "^0.5.0" + "@apollographql/graphql-playground-html" "1.6.27" "@apollographql/graphql-upload-8-fork" "^8.1.3" + "@josephg/resolvable" "^1.0.0" "@types/ws" "^7.0.0" - apollo-cache-control "^0.11.6" - apollo-datasource "^0.7.3" - apollo-graphql "^0.6.0" - apollo-reporting-protobuf "^0.6.2" - apollo-server-caching "^0.5.3" - apollo-server-env "^3.0.0" - apollo-server-errors "^2.4.2" - apollo-server-plugin-base "^0.10.4" - apollo-server-types "^0.6.3" - apollo-tracing "^0.12.2" + apollo-cache-control "^0.14.0" + apollo-datasource "^0.9.0" + apollo-graphql "^0.9.0" + apollo-reporting-protobuf "^0.8.0" + apollo-server-caching "^0.7.0" + apollo-server-env "^3.1.0" + apollo-server-errors "^2.5.0" + apollo-server-plugin-base "^0.13.0" + apollo-server-types "^0.9.0" + apollo-tracing "^0.15.0" async-retry "^1.2.1" fast-json-stable-stringify "^2.0.0" - graphql-extensions "^0.12.8" + graphql-extensions "^0.15.0" graphql-tag "^2.11.0" graphql-tools "^4.0.8" loglevel "^1.6.7" lru-cache "^6.0.0" sha.js "^2.4.11" - subscriptions-transport-ws "^0.9.11" + subscriptions-transport-ws "^0.9.19" uuid "^8.0.0" - ws "^6.0.0" -apollo-server-env@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-3.0.0.tgz#0157c51f52b63aee39af190760acf789ffc744d9" - integrity sha512-tPSN+VttnPsoQAl/SBVUpGbLA97MXG990XIwq6YUnJyAixrrsjW1xYG7RlaOqetxm80y5mBZKLrRDiiSsW/vog== +apollo-server-env@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-3.1.0.tgz#0733c2ef50aea596cc90cf40a53f6ea2ad402cd0" + integrity sha512-iGdZgEOAuVop3vb0F2J3+kaBVi4caMoxefHosxmgzAbbSpvWehB8Y1QiSyyMeouYC38XNVk5wnZl+jdGSsWsIQ== dependencies: - node-fetch "^2.1.2" + node-fetch "^2.6.1" util.promisify "^1.0.0" -apollo-server-errors@^2.4.2: - version "2.4.2" - resolved "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.2.tgz#1128738a1d14da989f58420896d70524784eabe5" - integrity sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ== +apollo-server-errors@^2.5.0: + version "2.5.0" + resolved "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.5.0.tgz#5d1024117c7496a2979e3e34908b5685fe112b68" + integrity sha512-lO5oTjgiC3vlVg2RKr3RiXIIQ5pGXBFxYGGUkKDhTud3jMIhs+gel8L8zsEjKaKxkjHhCQAA/bcEfYiKkGQIvA== apollo-server-express@^2.16.1, apollo-server-express@^2.21.0: - version "2.21.0" - resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.21.0.tgz#29bd4ec728e1992da240c5956c3ce6d95c1d252e" - integrity sha512-zbOSNGuxUjlOFZnRrbMpga3pKDEroitF4NAqoVxgBivx7v2hGsE7rljct3PucTx2cMN90AyYe3cU4oA8jBxZIQ== + version "2.25.2" + resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.25.2.tgz#58cd819694ff4c2dec6945a95c5dff6aa2719ef6" + integrity sha512-A2gF2e85vvDugPlajbhr0A14cDFDIGX0mteNOJ8P3Z3cIM0D4hwrWxJidI+SzobefDIyIHu1dynFedJVhV0euQ== dependencies: - "@apollographql/graphql-playground-html" "1.6.26" + "@apollographql/graphql-playground-html" "1.6.27" "@types/accepts" "^1.3.5" "@types/body-parser" "1.19.0" - "@types/cors" "2.8.8" - "@types/express" "4.17.7" - "@types/express-serve-static-core" "4.17.18" + "@types/cors" "2.8.10" + "@types/express" "^4.17.12" + "@types/express-serve-static-core" "^4.17.21" accepts "^1.3.5" - apollo-server-core "^2.21.0" - apollo-server-types "^0.6.3" + apollo-server-core "^2.25.2" + apollo-server-types "^0.9.0" body-parser "^1.18.3" - cors "^2.8.4" + cors "^2.8.5" express "^4.17.1" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.8" parseurl "^1.3.2" - subscriptions-transport-ws "^0.9.16" + subscriptions-transport-ws "^0.9.19" type-is "^1.6.16" -apollo-server-plugin-base@^0.10.4: - version "0.10.4" - resolved "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.10.4.tgz#fbf73f64f95537ca9f9639dd7c535eb5eeb95dcd" - integrity sha512-HRhbyHgHFTLP0ImubQObYhSgpmVH4Rk1BinnceZmwudIVLKrqayIVOELdyext/QnSmmzg5W7vF3NLGBcVGMqDg== +apollo-server-plugin-base@^0.13.0: + version "0.13.0" + resolved "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.13.0.tgz#3f85751a420d3c4625355b6cb3fbdd2acbe71f13" + integrity sha512-L3TMmq2YE6BU6I4Tmgygmd0W55L+6XfD9137k+cWEBFu50vRY4Re+d+fL5WuPkk5xSPKd/PIaqzidu5V/zz8Kg== dependencies: - apollo-server-types "^0.6.3" + apollo-server-types "^0.9.0" -apollo-server-types@^0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.6.3.tgz#f7aa25ff7157863264d01a77d7934aa6e13399e8" - integrity sha512-aVR7SlSGGY41E1f11YYz5bvwA89uGmkVUtzMiklDhZ7IgRJhysT5Dflt5IuwDxp+NdQkIhVCErUXakopocFLAg== +apollo-server-types@^0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.9.0.tgz#ccf550b33b07c48c72f104fbe2876232b404848b" + integrity sha512-qk9tg4Imwpk732JJHBkhW0jzfG0nFsLqK2DY6UhvJf7jLnRePYsPxWfPiNkxni27pLE2tiNlCwoDFSeWqpZyBg== dependencies: - apollo-reporting-protobuf "^0.6.2" - apollo-server-caching "^0.5.3" - apollo-server-env "^3.0.0" + apollo-reporting-protobuf "^0.8.0" + apollo-server-caching "^0.7.0" + apollo-server-env "^3.1.0" apollo-server@^2.16.1: version "2.21.0" @@ -7673,13 +7624,13 @@ apollo-server@^2.16.1: graphql-tools "^4.0.8" stoppable "^1.1.0" -apollo-tracing@^0.12.2: - version "0.12.2" - resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.12.2.tgz#a261c3970bb421b6dadf50cd85d75b2567a7e52c" - integrity sha512-SYN4o0C0wR1fyS3+P0FthyvsQVHFopdmN3IU64IaspR/RZScPxZ3Ae8uu++fTvkQflAkglnFM0aX6DkZERBp6w== +apollo-tracing@^0.15.0: + version "0.15.0" + resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.15.0.tgz#237fbbbf669aee4370b7e9081b685eabaa8ce84a" + integrity sha512-UP0fztFvaZPHDhIB/J+qGuy6hWO4If069MGC98qVs0I8FICIGu4/8ykpX3X3K6RtaQ56EDAWKykCxFv4ScxMeA== dependencies: - apollo-server-env "^3.0.0" - apollo-server-plugin-base "^0.10.4" + apollo-server-env "^3.1.0" + apollo-server-plugin-base "^0.13.0" apollo-upload-client@^13.0.0: version "13.0.0" @@ -7899,7 +7850,7 @@ arrify@^2.0.0, arrify@^2.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== -asap@^2.0.0, asap@~2.0.3, asap@~2.0.6: +asap@^2.0.0, asap@~2.0.3: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -8093,14 +8044,13 @@ axobject-query@^2.2.0: resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== -azure-devops-node-api@^10.1.1: - version "10.2.1" - resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.2.1.tgz#835080164f8c30cec6506c47198b044c053f1f36" - integrity sha512-XuSiUaYpk0tQpd9fD8qfRa5y1IdavupKNVmwxy0w/RhmxG2Wl8uAYnNJchUoWd3Rn9On0mYTCCZSn+UlYdYFSg== +azure-devops-node-api@^10.2.2: + version "10.2.2" + resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.2.2.tgz#9f557e622dd07bbaa9bd5e7e84e17c761e2151b2" + integrity sha512-4TVv2X7oNStT0vLaEfExmy3J4/CzfuXolEcQl/BRUmvGySqKStTG2O55/hUQ0kM7UJlZBLgniM0SBq4d/WkKow== dependencies: tunnel "0.0.6" - typed-rest-client "^1.8.0" - underscore "1.8.3" + typed-rest-client "^1.8.4" babel-code-frame@^6.22.0: version "6.26.0" @@ -9240,12 +9190,7 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" - integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== - -camelcase@^6.2.0: +camelcase@^6.0.0, camelcase@^6.2.0: version "6.2.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== @@ -9450,20 +9395,20 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.1: - version "3.5.1" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" - integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== +chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.2: + version "3.5.2" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" + integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== dependencies: - anymatch "~3.1.1" + anymatch "~3.1.2" braces "~3.0.2" - glob-parent "~5.1.0" + glob-parent "~5.1.2" is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.5.0" + readdirp "~3.6.0" optionalDependencies: - fsevents "~2.3.1" + fsevents "~2.3.2" chownr@^1.1.1: version "1.1.4" @@ -10275,12 +10220,17 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.8.2: +core-js-pure@^3.10.2, core-js-pure@^3.6.5: + version "3.15.1" + resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.15.1.tgz#8356f6576fa2aa8e54ca6fe02620968ff010eed7" + integrity sha512-OZuWHDlYcIda8sJLY4Ec6nWq2hRjlyCqCZ+jCflyleMkVt3tPedDVErvHslyS2nbO+SlBFMSBJYvtLMwxnrzjA== + +core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.8.2: version "3.15.0" resolved "https://registry.npmjs.org/core-js/-/core-js-3.15.0.tgz#db9554ebce0b6fd90dc9b1f2465c841d2d055044" integrity sha512-GUbtPllXMYRzIgHNZ4dTYTcUemls2cni83Q4Q/TrFONHfhcg9oEGOtaGHfb0cpzec60P96UKPvMkjX1jET8rUw== -core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: +core-js@^2.4.0, core-js@^2.6.10: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -10290,7 +10240,7 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cors@^2.8.4, cors@^2.8.5: +cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -10444,7 +10394,7 @@ cross-fetch@3.0.6: dependencies: node-fetch "2.6.1" -cross-fetch@3.1.4, cross-fetch@^3.0.4, cross-fetch@^3.0.6: +cross-fetch@3.1.4, cross-fetch@^3.0.4, cross-fetch@^3.0.6, cross-fetch@^3.1.3: version "3.1.4" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== @@ -10579,6 +10529,22 @@ css-loader@^3.5.3: schema-utils "^2.7.0" semver "^6.3.0" +css-loader@^5.2.6: + version "5.2.6" + resolved "https://registry.npmjs.org/css-loader/-/css-loader-5.2.6.tgz#c3c82ab77fea1f360e587d871a6811f4450cc8d1" + integrity sha512-0wyN5vXMQZu6BvjbrPdUJvkCzGEO24HC7IS7nW4llc6BBFC+zwR9CKtYGv63Puzsg10L/o12inMY5/2ByzfD6w== + dependencies: + icss-utils "^5.1.0" + loader-utils "^2.0.0" + postcss "^8.2.15" + postcss-modules-extract-imports "^3.0.0" + postcss-modules-local-by-default "^4.0.0" + postcss-modules-scope "^3.0.0" + postcss-modules-values "^4.0.0" + postcss-value-parser "^4.1.0" + schema-utils "^3.0.0" + semver "^7.3.5" + css-select-base-adapter@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" @@ -13509,6 +13475,15 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" +fs-extra@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz#9ff61b655dde53fb34a82df84bb214ce802e17c1" + integrity sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.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" @@ -13527,7 +13502,7 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: +fs-extra@^9.0.0, fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -13584,7 +13559,7 @@ fsevents@^2.1.2: resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== -fsevents@~2.3.1: +fsevents@~2.3.1, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -13927,7 +13902,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.0: +glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -14284,14 +14259,14 @@ graphql-config@^3.0.2, graphql-config@^3.3.0: minimatch "3.0.4" string-env-interpolation "1.0.1" -graphql-extensions@^0.12.8: - version "0.12.8" - resolved "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.8.tgz#9cdc2c43d8fe5e0f6c3177a004ac011da2a8aa0f" - integrity sha512-xjsSaB6yKt9jarFNNdivl2VOx52WySYhxPgf8Y16g6GKZyAzBoIFiwyGw5PJDlOSUa6cpmzn6o7z8fVMbSAbkg== +graphql-extensions@^0.15.0: + version "0.15.0" + resolved "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.15.0.tgz#3f291f9274876b0c289fa4061909a12678bd9817" + integrity sha512-bVddVO8YFJPwuACn+3pgmrEg6I8iBuYLuwvxiE+lcQQ7POotVZxm2rgGw0PvVYmWWf3DT7nTVDZ5ROh/ALp8mA== dependencies: - "@apollographql/apollo-tools" "^0.4.3" - apollo-server-env "^3.0.0" - apollo-server-types "^0.6.3" + "@apollographql/apollo-tools" "^0.5.0" + apollo-server-env "^3.1.0" + apollo-server-types "^0.9.0" graphql-language-service-interface@2.8.2, graphql-language-service-interface@^2.8.2: version "2.8.2" @@ -15030,7 +15005,7 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" -icss-utils@^5.0.0: +icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== @@ -16959,9 +16934,9 @@ json5@^1.0.1: minimist "^1.2.0" json5@^2.1.1, json5@^2.1.2, json5@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" - integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + version "2.2.0" + resolved "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== dependencies: minimist "^1.2.5" @@ -19010,7 +18985,7 @@ nan@2.14.1, nan@^2.12.1, nan@^2.14.0: resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== -nano-css@^5.1.0, nano-css@^5.2.1, nano-css@^5.3.1: +nano-css@^5.1.0, nano-css@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.1.tgz#b709383e07ad3be61f64edffacb9d98250b87a1f" integrity sha512-ENPIyNzANQRyYVvb62ajDd7PAyIgS2LIUnT9ewih4yrXSZX4hKoUwssy8WjUH++kEOA5wUTMgNnV7ko5n34kUA== @@ -19024,15 +18999,10 @@ nano-css@^5.1.0, nano-css@^5.2.1, nano-css@^5.3.1: stacktrace-js "^2.0.2" stylis "^4.0.6" -nanoid@^2.1.0: - version "2.1.11" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" - integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== - -nanoid@^3.1.22: - version "3.1.22" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844" - integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ== +nanoid@^3.1.23: + version "3.1.23" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" + integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== nanomatch@^1.2.9: version "1.2.13" @@ -19144,10 +19114,10 @@ node-abi@^2.7.0: dependencies: semver "^5.4.1" -node-addon-api@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" - integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-addon-api@^3.0.0: + version "3.2.1" + resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== node-cache@^5.1.2: version "5.1.2" @@ -19163,7 +19133,7 @@ node-dir@^0.1.10, node-dir@^0.1.17: dependencies: minimatch "^3.0.2" -node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -19841,9 +19811,9 @@ optionator@^0.9.1: word-wrap "^1.2.3" ora@^5.3.0: - version "5.4.0" - resolved "https://registry.npmjs.org/ora/-/ora-5.4.0.tgz#42eda4855835b9cd14d33864c97a3c95a3f56bf4" - integrity sha512-1StwyXQGoU6gdjYkyVcqOLnVlbKj+6yPNNOxJVgpt9t4eksKjiriiHuxktLYkgllwk+D6MbC4ihH84L1udRXPg== + version "5.4.1" + resolved "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== dependencies: bl "^4.1.0" chalk "^4.1.0" @@ -20350,9 +20320,9 @@ passport-oauth2@1.2.0: uid2 "0.0.x" passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" - integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== + version "1.6.0" + resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.6.0.tgz#5f599735e0ea40ea3027643785f81a3a9b4feb50" + integrity sha512-emXPLqLcVEcLFR/QvQXZcwLmfK8e9CqvMgmOFJxcNT3okSFMtUbRRKpY20x5euD+01uHsjjCa07DYboEeLXYiw== dependencies: base64url "3.x.x" oauth "0.9.x" @@ -21217,14 +21187,14 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ source-map "^0.6.1" supports-color "^6.1.0" -postcss@^8.0.2, postcss@^8.1.0: - version "8.2.13" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.2.13.tgz#dbe043e26e3c068e45113b1ed6375d2d37e2129f" - integrity sha512-FCE5xLH+hjbzRdpbRb1IMCvPv9yZx2QnDarBEYSN0N0HYk+TcXsEhwdFcFb+SRWOKzKGErhIEbBK2ogyLdTtfQ== +postcss@^8.0.2, postcss@^8.1.0, postcss@^8.2.15: + version "8.3.5" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.3.5.tgz#982216b113412bc20a86289e91eb994952a5b709" + integrity sha512-NxTuJocUhYGsMiMFHDUkmjSKT3EdH4/WbGF6GCi1NDGk+vbcUTun4fpbOqaPtD8IIsztA2ilZm2DhYCuyN58gA== dependencies: colorette "^1.2.2" - nanoid "^3.1.22" - source-map "^0.6.1" + nanoid "^3.1.23" + source-map-js "^0.6.2" postgres-array@~1.0.0: version "1.0.3" @@ -21434,13 +21404,6 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -promise@^8.0.3: - version "8.1.0" - resolved "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz#697c25c3dfe7435dd79fcd58c38a135888eaf05e" - integrity sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q== - dependencies: - asap "~2.0.6" - prompts@2.4.0, prompts@^2.0.1: version "2.4.0" resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" @@ -21806,18 +21769,6 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-app-polyfill@^1.0.4: - version "1.0.6" - resolved "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" - integrity sha512-OfBnObtnGgLGfweORmdZbyEz+3dgVePQBb3zipiaDsMHV1NpWm0rDFYIVXFV/AK+x4VIIfWHhrdMIeoTLyRr2g== - dependencies: - core-js "^3.5.0" - object-assign "^4.1.1" - promise "^8.0.3" - raf "^3.4.1" - regenerator-runtime "^0.13.3" - whatwg-fetch "^3.0.0" - react-beautiful-dnd@^13.0.0: version "13.0.0" resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#f70cc8ff82b84bc718f8af157c9f95757a6c3b40" @@ -22339,26 +22290,6 @@ react-use@^12.2.0: ts-easing "^0.2.0" tslib "^1.10.0" -react-use@^15.3.3: - version "15.3.8" - resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.8.tgz#ca839ac7fb3d696e5ccbeabbc8dadc2698969d30" - integrity sha512-GeGcrmGuUvZrY5wER3Lnph9DSYhZt5nEjped4eKDq8BRGr2CnLf9bDQWG9RFc7oCPphnscUUdOovzq0E5F2c6Q== - dependencies: - "@types/js-cookie" "2.2.6" - "@xobotyi/scrollbar-width" "1.9.5" - copy-to-clipboard "^3.2.0" - fast-deep-equal "^3.1.3" - fast-shallow-equal "^1.0.0" - js-cookie "^2.2.1" - nano-css "^5.2.1" - react-universal-interface "^0.6.2" - resize-observer-polyfill "^1.5.1" - screenfull "^5.0.0" - set-harmonic-interval "^1.0.1" - throttle-debounce "^2.1.0" - ts-easing "^0.2.0" - tslib "^2.0.0" - react-use@^17.2.4: version "17.2.4" resolved "https://registry.npmjs.org/react-use/-/react-use-17.2.4.tgz#1f89be3db0a8237c79253db0a15e12bbe3cfeff1" @@ -22591,10 +22522,10 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -readdirp@~3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" - integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== dependencies: picomatch "^2.2.1" @@ -22741,7 +22672,7 @@ regenerator-runtime@^0.11.0: resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: +regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: version "0.13.7" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== @@ -23724,13 +23655,6 @@ shellwords@^0.1.1: resolved "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== -shortid@^2.2.14: - version "2.2.15" - resolved "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz#2b902eaa93a69b11120373cd42a1f1fe4437c122" - integrity sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw== - dependencies: - nanoid "^2.1.0" - shx@^0.3.2: version "0.3.3" resolved "https://registry.npmjs.org/shx/-/shx-0.3.3.tgz#681a88c7c10db15abe18525349ed474f0f1e7b9f" @@ -23963,6 +23887,11 @@ source-list-map@^2.0.0: resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + source-map-resolve@^0.5.0: version "0.5.3" resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" @@ -24144,12 +24073,12 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -sqlite3@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.0.tgz#1bfef2151c6bc48a3ab1a6c126088bb8dd233566" - integrity sha512-rjvqHFUaSGnzxDy2AHCwhHy6Zp6MNJzCPGYju4kD8yi6bze4d1/zMTg6C7JI49b7/EM7jKMTvyfN/4ylBKdwfw== +sqlite3@^5.0.1: + version "5.0.2" + resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz#00924adcc001c17686e0a6643b6cbbc2d3965083" + integrity sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA== dependencies: - node-addon-api "2.0.0" + node-addon-api "^3.0.0" node-pre-gyp "^0.11.0" optionalDependencies: node-gyp "3.x" @@ -24705,16 +24634,16 @@ stylis@^4.0.6: resolved "https://registry.npmjs.org/stylis/-/stylis-4.0.7.tgz#412a90c28079417f3d27c028035095e4232d2904" integrity sha512-OFFeUXFgwnGOKvEXaSv0D0KQ5ADP0n6g3SVONx6I/85JzNZ3u50FRwB3lVIk1QO2HNdI75tbVzc4Z66Gdp9voA== -subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16, subscriptions-transport-ws@^0.9.18: - version "0.9.18" - resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz#bcf02320c911fbadb054f7f928e51c6041a37b97" - integrity sha512-tztzcBTNoEbuErsVQpTN2xUNN/efAZXyCyL5m3x4t6SKrEiTL2N8SaKWBFWM4u56pL79ULif3zjyeq+oV+nOaA== +subscriptions-transport-ws@^0.9.18, subscriptions-transport-ws@^0.9.19: + version "0.9.19" + resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.19.tgz#10ca32f7e291d5ee8eb728b9c02e43c52606cdcf" + integrity sha512-dxdemxFFB0ppCLg10FTtRqH/31FNRL1y1BQv8209MK5I4CwALb7iihQg+7p65lFcIl8MHatINWBLOqpgU4Kyyw== dependencies: backo2 "^1.0.2" eventemitter3 "^3.1.0" iterall "^1.2.1" symbol-observable "^1.0.4" - ws "^5.2.0" + ws "^5.2.0 || ^6.0.0 || ^7.0.0" sucrase@^3.18.0, sucrase@^3.18.2: version "3.18.2" @@ -25579,6 +25508,22 @@ ts-log@^2.2.3: resolved "https://registry.npmjs.org/ts-log/-/ts-log-2.2.3.tgz#4da5640fe25a9fb52642cd32391c886721318efb" integrity sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w== +ts-node@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.0.0.tgz#05f10b9a716b0b624129ad44f0ea05dac84ba3be" + integrity sha512-ROWeOIUvfFbPZkoDis0L/55Fk+6gFQNZwwKPLinacRl6tsxstTF1DbAcLKkovwnpKMVvOMHP1TIbnwXwtLg1gg== + dependencies: + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + ts-node@^9, ts-node@^9.1.1: version "9.1.1" resolved "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" @@ -25759,14 +25704,14 @@ type@^2.0.0: resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== -typed-rest-client@^1.8.0: - version "1.8.0" - resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.0.tgz#3b6c22a7cc31b665ec1e4bedb3482ebe12e2fbe6" - integrity sha512-Nu1MrdH6ECrRW5gHoRAdubgCs4oH6q5/J76jsEC8bVDfvVoVPkigukPalhMHPwb7ZvpsZqPptd5zpt/QdtrdBw== +typed-rest-client@^1.8.4: + version "1.8.4" + resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.4.tgz#ba3fb788e5b9322547406392533f12d660a5ced6" + integrity sha512-MyfKKYzk3I6/QQp6e1T50py4qg+c+9BzOEl2rBmQIpStwNUoqQ73An+Tkfy9YuV7O+o2mpVVJpe+fH//POZkbg== dependencies: qs "^6.9.1" tunnel "0.0.6" - underscore "1.8.3" + underscore "^1.12.1" typedarray-to-buffer@^3.1.5: version "3.1.5" @@ -25865,10 +25810,10 @@ undefsafe@^2.0.3: dependencies: debug "^2.2.0" -underscore@1.8.3: - version "1.8.3" - resolved "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" - integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI= +underscore@^1.12.1: + version "1.13.1" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" + integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== underscore@^1.9.1: version "1.11.0" @@ -26185,10 +26130,10 @@ use-composed-ref@^1.0.0: dependencies: ts-essentials "^2.0.3" -use-immer@^0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/use-immer/-/use-immer-0.5.1.tgz#3862ebbb194ebfe66b8eb221082c2fb2585f8fe1" - integrity sha512-Orb7PokM+jiLQfA1oJ3B3P7Guq0c2IxUHHmBpLeEMnJiz4ZOMlj9mkqq6D7iXJsnurRX0EOB134annf3RjEWHw== +use-immer@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/use-immer/-/use-immer-0.6.0.tgz#ca6aa5ade93018e2c65cf128d19ada54fc23f70d" + integrity sha512-dFGRfvWCqPDTOt/S431ETYTg6+uxbpb7A1pptufwXVzGJY3RlXr38+3wyLNpc6SbbmAKjWl6+EP6uW74fkEsXQ== use-isomorphic-layout-effect@^1.0.0: version "1.0.0" @@ -26666,7 +26611,7 @@ whatwg-fetch@^2.0.4: resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== -whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.1: +whatwg-fetch@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== @@ -26952,7 +26897,12 @@ ws@^5.2.0: dependencies: async-limiter "~1.0.0" -ws@^6.0.0, ws@^6.1.2, ws@^6.2.1: +"ws@^5.2.0 || ^6.0.0 || ^7.0.0": + version "7.5.0" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.0.tgz#0033bafea031fb9df041b2026fc72a571ca44691" + integrity sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw== + +ws@^6.1.2, ws@^6.2.1: version "6.2.1" resolved "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== @@ -27070,10 +27020,10 @@ xpath@0.0.27: resolved "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== -xss@^1.0.6: - version "1.0.7" - resolved "https://registry.npmjs.org/xss/-/xss-1.0.7.tgz#a554cbd5e909324bd6893fb47fff441ad54e2a95" - integrity sha512-A9v7tblGvxu8TWXQC9rlpW96a+LN1lyw6wyhpTmmGW+FwRMactchBR3ROKSi33UPCUcUHSu8s9YP6F+K3Mw//w== +xss@^1.0.8: + version "1.0.9" + resolved "https://registry.npmjs.org/xss/-/xss-1.0.9.tgz#3ffd565571ff60d2e40db7f3b80b4677bec770d2" + integrity sha512-2t7FahYnGJys6DpHLhajusId7R0Pm2yTmuL0GV9+mV0ZlaLSnb2toBmppATfg5sWIhZQGlsTLoecSzya+l4EAQ== dependencies: commander "^2.20.3" cssfilter "0.0.10"