diff --git a/.changeset/big-cougars-glow.md b/.changeset/big-cougars-glow.md new file mode 100644 index 0000000000..d1630905db --- /dev/null +++ b/.changeset/big-cougars-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/dev-utils': patch +--- + +Add theme switcher to sidebar of dev app. diff --git a/.changeset/big-months-float.md b/.changeset/big-months-float.md new file mode 100644 index 0000000000..9acb7002e7 --- /dev/null +++ b/.changeset/big-months-float.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is +duplicated in the tabs above. diff --git a/.changeset/blue-bikes-explode.md b/.changeset/blue-bikes-explode.md new file mode 100644 index 0000000000..1c10a08a46 --- /dev/null +++ b/.changeset/blue-bikes-explode.md @@ -0,0 +1,14 @@ +--- +'@backstage/config-loader': minor +--- + +Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`. + +This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users. + +If you use `loadConfig` directly you will need to update your usage from: + +```diff +- const appConfigs = await loadConfig(options) ++ const { appConfigs } = await loadConfig(options) +``` diff --git a/.changeset/cost-insights-two-crabs-evolve.md b/.changeset/cost-insights-two-crabs-evolve.md new file mode 100644 index 0000000000..8a932ea59c --- /dev/null +++ b/.changeset/cost-insights-two-crabs-evolve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Fixed generation of sample data in the example Cost Insights client diff --git a/.changeset/cyan-bottles-hug.md b/.changeset/cyan-bottles-hug.md new file mode 100644 index 0000000000..7c2083c4ff --- /dev/null +++ b/.changeset/cyan-bottles-hug.md @@ -0,0 +1,8 @@ +--- +'@backstage/cli': patch +'@backstage/cli-common': patch +--- + +Keep backstage.json in sync + +The `versions:bump` script now takes care about updating the `version` property inside `backstage.json` file. The file is created if is not present. diff --git a/.changeset/early-cobras-explode.md b/.changeset/early-cobras-explode.md new file mode 100644 index 0000000000..34b4cb0e98 --- /dev/null +++ b/.changeset/early-cobras-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Update to the right version of @backstage/errors diff --git a/.changeset/empty-dots-attend.md b/.changeset/empty-dots-attend.md new file mode 100644 index 0000000000..3903dfa22b --- /dev/null +++ b/.changeset/empty-dots-attend.md @@ -0,0 +1,8 @@ +--- +'@backstage/catalog-model': patch +'@backstage/cli': patch +'@backstage/config-loader': patch +'@backstage/plugin-scaffolder': patch +--- + +Update the json-schema dependency version. diff --git a/.changeset/fluffy-moles-deny.md b/.changeset/fluffy-moles-deny.md new file mode 100644 index 0000000000..4b4abc36fa --- /dev/null +++ b/.changeset/fluffy-moles-deny.md @@ -0,0 +1,42 @@ +--- +'@backstage/plugin-allure': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-azure-devops': patch +'@backstage/plugin-badges': patch +'@backstage/plugin-bazaar': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-code-coverage': patch +'@backstage/plugin-config-schema': patch +'@backstage/plugin-cost-insights': patch +'@backstage/plugin-explore': patch +'@backstage/plugin-firehydrant': patch +'@backstage/plugin-gcp-projects': patch +'@backstage/plugin-git-release-manager': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-gitops-profiles': patch +'@backstage/plugin-home': patch +'@backstage/plugin-ilert': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-kafka': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-newrelic': patch +'@backstage/plugin-pagerduty': patch +'@backstage/plugin-rollbar': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-splunk-on-call': patch +'@backstage/plugin-tech-radar': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-todo': patch +'@backstage/plugin-user-settings': patch +'@backstage/plugin-xcmetrics': patch +--- + +Refactor out the deprecated path and icon from RouteRefs diff --git a/.changeset/forty-ligers-protect.md b/.changeset/forty-ligers-protect.md new file mode 100644 index 0000000000..e3e46fb7f3 --- /dev/null +++ b/.changeset/forty-ligers-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +remove double config dep diff --git a/.changeset/fresh-zebras-hug.md b/.changeset/fresh-zebras-hug.md new file mode 100644 index 0000000000..c295dc2287 --- /dev/null +++ b/.changeset/fresh-zebras-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kafka-backend': patch +--- + +Update Kafka configuration types diff --git a/.changeset/happy-rice-tickle.md b/.changeset/happy-rice-tickle.md new file mode 100644 index 0000000000..1e39dc2e9c --- /dev/null +++ b/.changeset/happy-rice-tickle.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added the `isDatabaseConflictError` function. diff --git a/.changeset/healthy-kids-mate.md b/.changeset/healthy-kids-mate.md new file mode 100644 index 0000000000..10e8222a15 --- /dev/null +++ b/.changeset/healthy-kids-mate.md @@ -0,0 +1,11 @@ +--- +'@backstage/create-app': patch +--- + +Create backstage.json file + +`@backstage/create-app` will create a new `backstage.json` file. At this point, the file will contain a `version` property, representing the version of `@backstage/create-app` used for creating the application. If the backstage's application has been bootstrapped using an older version of `@backstage/create-app`, the `backstage.json` file can be created and kept in sync, together with all the changes of the latest version of backstage, by running the following script: + +```bash +yarn backstage-cli versions:bump +``` diff --git a/.changeset/hungry-wombats-happen.md b/.changeset/hungry-wombats-happen.md new file mode 100644 index 0000000000..018e7ae9e8 --- /dev/null +++ b/.changeset/hungry-wombats-happen.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/cli': patch +--- + +Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. diff --git a/.changeset/khaki-rice-kick.md b/.changeset/khaki-rice-kick.md new file mode 100644 index 0000000000..54819b855a --- /dev/null +++ b/.changeset/khaki-rice-kick.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-user-settings': patch +--- + +Add Props Icon for Sidebar Item SidebarSearchField and Settings diff --git a/.changeset/little-news-retire.md b/.changeset/little-news-retire.md new file mode 100644 index 0000000000..4304cf2d19 --- /dev/null +++ b/.changeset/little-news-retire.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-bazaar': patch +'@backstage/plugin-bazaar-backend': patch +--- + +A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. diff --git a/.changeset/little-numbers-thank.md b/.changeset/little-numbers-thank.md new file mode 100644 index 0000000000..f444a904c0 --- /dev/null +++ b/.changeset/little-numbers-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window. diff --git a/.changeset/lucky-ads-shout.md b/.changeset/lucky-ads-shout.md new file mode 100644 index 0000000000..73063bb911 --- /dev/null +++ b/.changeset/lucky-ads-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Add deprecation warnings around `title` `icon` and `path` as they are no longer controlled when creating `routeRefs` diff --git a/.changeset/many-sloths-cross.md b/.changeset/many-sloths-cross.md new file mode 100644 index 0000000000..07cb6d2f61 --- /dev/null +++ b/.changeset/many-sloths-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Refactor and add regression tests for create-app tasks diff --git a/.changeset/metal-donuts-cross.md b/.changeset/metal-donuts-cross.md new file mode 100644 index 0000000000..6c2699ed61 --- /dev/null +++ b/.changeset/metal-donuts-cross.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +This fixes a bug where locations couldn't be added unless the processing engine is started. +It's now possible to run the catalog backend without starting the processing engine and still allowing locations registrations. + +This is done by refactor the `EntityProvider.connect` to happen outside the engine. diff --git a/.changeset/nasty-impalas-travel.md b/.changeset/nasty-impalas-travel.md new file mode 100644 index 0000000000..f6eb321628 --- /dev/null +++ b/.changeset/nasty-impalas-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Paths can be specified in backend.reading.allow to further restrict allowed targets diff --git a/.changeset/new-cobras-fly.md b/.changeset/new-cobras-fly.md new file mode 100644 index 0000000000..f39f1b0fbb --- /dev/null +++ b/.changeset/new-cobras-fly.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops': patch +--- + +Added entity view for Azure Repo Pull Requests diff --git a/.changeset/nine-bananas-mate.md b/.changeset/nine-bananas-mate.md new file mode 100644 index 0000000000..e119eb722f --- /dev/null +++ b/.changeset/nine-bananas-mate.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-app-api': patch +'@backstage/test-utils': patch +--- + +Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. diff --git a/.changeset/ninety-grapes-love.md b/.changeset/ninety-grapes-love.md new file mode 100644 index 0000000000..9143bddd30 --- /dev/null +++ b/.changeset/ninety-grapes-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-auth-backend': patch +--- + +Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken diff --git a/.changeset/ninety-spies-prove.md b/.changeset/ninety-spies-prove.md new file mode 100644 index 0000000000..509aedd66f --- /dev/null +++ b/.changeset/ninety-spies-prove.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Skip empty file names when scaffolding with nunjucks diff --git a/.changeset/orange-experts-approve.md b/.changeset/orange-experts-approve.md new file mode 100644 index 0000000000..13d1b022da --- /dev/null +++ b/.changeset/orange-experts-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Adjust entity query construction to ensure sub-queries are always isolated from one another. diff --git a/.changeset/pretty-moons-drive.md b/.changeset/pretty-moons-drive.md new file mode 100644 index 0000000000..51dc69332e --- /dev/null +++ b/.changeset/pretty-moons-drive.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Adding config prop `pluginDivisionMode` to allow plugins using the `pg` client to create their own management schemas in the db. This allows `pg` client plugins to work in separate schemas in the same db. diff --git a/.changeset/proud-bottles-cheat.md b/.changeset/proud-bottles-cheat.md new file mode 100644 index 0000000000..0c724352c7 --- /dev/null +++ b/.changeset/proud-bottles-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Add `AbortSignal` support to `UrlReader` diff --git a/.changeset/rare-lemons-boil.md b/.changeset/rare-lemons-boil.md new file mode 100644 index 0000000000..b882ab0b91 --- /dev/null +++ b/.changeset/rare-lemons-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Deprecated the `BackstagePluginWithAnyOutput` type. diff --git a/.changeset/rotten-clouds-kick.md b/.changeset/rotten-clouds-kick.md new file mode 100644 index 0000000000..2c4772c6e5 --- /dev/null +++ b/.changeset/rotten-clouds-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Start exporting and marking several types as public to address errors in the API report. diff --git a/.changeset/search-serious-hornets-design.md b/.changeset/search-serious-hornets-design.md new file mode 100644 index 0000000000..638f8ae73b --- /dev/null +++ b/.changeset/search-serious-hornets-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Smaller UX improvements to search components such as optional autoFocus prop to SearchBar components, decreased debounce value and closing modal on link click of SearchModal, terminology updates of SearchResultPager. diff --git a/.changeset/shiny-starfishes-float.md b/.changeset/shiny-starfishes-float.md new file mode 100644 index 0000000000..0673791375 --- /dev/null +++ b/.changeset/shiny-starfishes-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a scaffolder backend module template for the `create` command. diff --git a/.changeset/silent-taxis-tan.md b/.changeset/silent-taxis-tan.md new file mode 100644 index 0000000000..286ef40a4c --- /dev/null +++ b/.changeset/silent-taxis-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Remove exports of unused types(`RouteOptions` and `RoutePath`). diff --git a/.changeset/sour-cameras-hide.md b/.changeset/sour-cameras-hide.md new file mode 100644 index 0000000000..a31e7a13c3 --- /dev/null +++ b/.changeset/sour-cameras-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead. diff --git a/.changeset/spicy-rice-build.md b/.changeset/spicy-rice-build.md new file mode 100644 index 0000000000..c0322d778a --- /dev/null +++ b/.changeset/spicy-rice-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output. diff --git a/.changeset/sweet-suits-sit.md b/.changeset/sweet-suits-sit.md new file mode 100644 index 0000000000..b6f7fbadf5 --- /dev/null +++ b/.changeset/sweet-suits-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-shortcuts': patch +--- + +Add tooltip to display shortcut title on hover. This should improve the readability of shortcuts with long names, which are cutoff by ellipsis. diff --git a/.changeset/tame-buckets-move.md b/.changeset/tame-buckets-move.md new file mode 100644 index 0000000000..f7ee3deb90 --- /dev/null +++ b/.changeset/tame-buckets-move.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-azure-devops': patch +'@backstage/plugin-azure-devops-backend': patch +'@backstage/plugin-azure-devops-common': minor +--- + +Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime diff --git a/.changeset/techdocs-chatt-months-report-too.md b/.changeset/techdocs-chatt-months-report-too.md new file mode 100644 index 0000000000..612a4506a5 --- /dev/null +++ b/.changeset/techdocs-chatt-months-report-too.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Fixed entity triplet case handling for certain locales. diff --git a/.changeset/techdocs-chatty-months-report.md b/.changeset/techdocs-chatty-months-report.md new file mode 100644 index 0000000000..7d3fa773bf --- /dev/null +++ b/.changeset/techdocs-chatty-months-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Added OpenStack Swift case migration support. diff --git a/.changeset/techdocs-famous-donuts-warn.md b/.changeset/techdocs-famous-donuts-warn.md new file mode 100644 index 0000000000..2fd9346ee2 --- /dev/null +++ b/.changeset/techdocs-famous-donuts-warn.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': patch +--- + +Add support for specifying bucketRootPath for AWS and GCS publishers diff --git a/.changeset/techdocs-orange-cougars-relax.md b/.changeset/techdocs-orange-cougars-relax.md new file mode 100644 index 0000000000..376de6a0e5 --- /dev/null +++ b/.changeset/techdocs-orange-cougars-relax.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets diff --git a/.changeset/techdocs-real-geese-dress.md b/.changeset/techdocs-real-geese-dress.md new file mode 100644 index 0000000000..0d5f1978f9 --- /dev/null +++ b/.changeset/techdocs-real-geese-dress.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': patch +--- + +Adds ability to use encrypted S3 buckets by utilizing the SSE option in the AWS SDK diff --git a/.changeset/techdocs-sharp-knives-unite.md b/.changeset/techdocs-sharp-knives-unite.md new file mode 100644 index 0000000000..aa3625b883 --- /dev/null +++ b/.changeset/techdocs-sharp-knives-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Use a better checkbox rendering in a task list. diff --git a/.changeset/techdocs-silver-plums-speak.md b/.changeset/techdocs-silver-plums-speak.md new file mode 100644 index 0000000000..84d04fc1ef --- /dev/null +++ b/.changeset/techdocs-silver-plums-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. diff --git a/.changeset/ten-planes-remember.md b/.changeset/ten-planes-remember.md new file mode 100644 index 0000000000..3fefe650af --- /dev/null +++ b/.changeset/ten-planes-remember.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-tech-insights-backend': patch +--- + +Add catalog fact retrievers + +Add fact retrievers which generate facts related to the completeness +of entity data in the catalog. diff --git a/.changeset/tender-gorillas-peel.md b/.changeset/tender-gorillas-peel.md new file mode 100644 index 0000000000..f7d7022740 --- /dev/null +++ b/.changeset/tender-gorillas-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1 diff --git a/.changeset/tidy-beans-reflect.md b/.changeset/tidy-beans-reflect.md new file mode 100644 index 0000000000..20796075f0 --- /dev/null +++ b/.changeset/tidy-beans-reflect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. diff --git a/.changeset/violet-panthers-care.md b/.changeset/violet-panthers-care.md new file mode 100644 index 0000000000..7a43c87f37 --- /dev/null +++ b/.changeset/violet-panthers-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +adds getDefaultProcessor method to CatalogBuilder diff --git a/.changeset/yellow-deers-act.md b/.changeset/yellow-deers-act.md new file mode 100644 index 0000000000..a9008ea41e --- /dev/null +++ b/.changeset/yellow-deers-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types. diff --git a/.changeset/yellow-falcons-whisper.md b/.changeset/yellow-falcons-whisper.md new file mode 100644 index 0000000000..63a5d42fb0 --- /dev/null +++ b/.changeset/yellow-falcons-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Expose template metadata to custom action handler in Scaffolder. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b637524a52..9be92d294e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,7 +18,9 @@ /plugins/techdocs-backend @backstage/techdocs-core /plugins/ilert @backstage/reviewers @yacut /plugins/home @backstage/techdocs-core +/packages/embedded-techdocs-app @backstage/techdocs-core /packages/search-common @backstage/techdocs-core +/packages/techdocs-cli @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7e478953bc..d65e31b4c4 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -52,6 +52,7 @@ configmaps configs const cookiecutter +cron css Datadog dataflow @@ -62,6 +63,7 @@ Debounce declaratively deduplicated deps +dependabot destructured dev devops @@ -162,6 +164,8 @@ Monorepo monorepos msgraph msw +mutex +mutexes mysql namespace namespaced diff --git a/.github/workflows/techdocs-e2e.yml b/.github/workflows/techdocs-e2e.yml new file mode 100644 index 0000000000..191274ec84 --- /dev/null +++ b/.github/workflows/techdocs-e2e.yml @@ -0,0 +1,42 @@ +name: Techdocs E2E Test + +on: + pull_request: + paths-ignore: + - '.changeset/**' + - 'contrib/**' + - 'docs/**' + - 'microsite/**' + +jobs: + verify: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + + - name: install dependencies + run: yarn install --frozen-lockfile + + - name: generate types + run: yarn tsc + + - name: build techdocs-cli + working-directory: packages/techdocs-cli + run: yarn build + + - name: Install mkdocs & techdocs-core + run: python -m pip install mkdocs-techdocs-core + + - name: techdocs-cli e2e test + working-directory: packages/techdocs-cli + run: yarn test:e2e:ci diff --git a/ADOPTERS.md b/ADOPTERS.md index 6736538fa0..92912fe144 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -64,4 +64,8 @@ | [SoundCloud](https://www.soundcloud.com) | [Julio Zynger](https://github.com/julioz) | Developer portal as a [humane registry](https://martinfowler.com/bliki/HumaneRegistry.html) for the organization: catalog of people, services, documentation, feature toggles, escalation policies, etc. | | [Volvofinans Bank](https://www.volvofinans.se) | [Johan Hammar](https://github.com/johanhammar) | Developer portal enabling engineers to manage and explore software and documentation. | | [Palo Alto Networks](https://www.paloaltonetworks.com) | [Jeremy Guarini](https://github.com/jeremyguarini), [Brian Lomeland](https://github.com/bbbmmmlll), [Palo Alto Networks](https://github.com/PaloAltoNetworks) | Developer portal, service catalog, documentation and tooling | -| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem +| [Signal Iduna Group](https://www.signal-iduna.de/) | [Jonas Thomsen](https://github.com/JoThomsen) | Developer Portal, documentation, monitoring, service catalog for our insurance ecosystem | +| [Tradeshift](https://www.tradeshift.com/) | [Soren Mathiasen](https://github.com/sorenmat) | Developer Portal: documentation, monitoring, service templates, service catalog for our micro services | +| [Unity](https://unity.com) | [Ted Cordery](https://github.com/TeddyBallGame) | A centralized service catalog with documentation for our service engineers. | +| [PicPay](https://www.picpay.com) | [Luis Baroni](https://github.com/lcsbaroni), [Renata Poluceno](https://github.com/renatapoluceno), [PicPay](https://github.com/picpay) | Developer portal for building services throught templates, service catalog with ownership of services, documentation and metrics providing autonomy and visibility for all. | +| [Epic Games](https://www.epicgames.com) | [Brian Jung](https://github.com/brian-at-epic), [Jeff Goldian](https://github.com/jeffgoldian-Epic) | Developer Portal: Service Catalog, Documentation, Software Templates and more making our internal teams' lives easier! | diff --git a/docs/FAQ.md b/docs/FAQ.md index c95e10ad4e..ea4ae352dd 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,7 +117,7 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. -### Why can't I dynamically install plugins without modifications the app? +### Why can't I dynamically install plugins without modifications to the app? This decision is part of the core architecture and development flow of Backstage. Plugins have a lot of freedom in what they provide and how they are diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 1b364bcdae..133653df44 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -199,7 +199,7 @@ export a class that `implements` the target API, for example: ```ts export class IgnoringErrorApi implements ErrorApi { - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { // ignore error } } diff --git a/docs/assets/features/techdocs/techdocs-cli-serve-preview.png b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png new file mode 100644 index 0000000000..bcd48982f4 Binary files /dev/null and b/docs/assets/features/techdocs/techdocs-cli-serve-preview.png differ diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 7100e86ed0..57473ce4c7 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -144,7 +144,8 @@ spec: Note that to be able to read from targets that are outside of the normal integration points such as `github.com`, you'll need to explicitly allow it by -adding an entry in the `backend.reading.allow` list. For example: +adding an entry in the `backend.reading.allow` list. Paths can be specified to +further restrict targets For example: ```yml backend: @@ -153,6 +154,8 @@ backend: allow: - host: example.com - host: '*.examples.org' + - host: example.net + paths: ['/api/'] ``` ## Common to All Kinds: The Envelope diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 07c4891a6c..a564200bfe 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -8,6 +8,10 @@ If you're wanting to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used along side our [built-in actions](./builtin-actions.md). +> Note: When adding custom actions, the actions array will **replace the +> built-in actions too**. To ensure you can continue to include he builtin +> actions, see below to include them during registration of your action. + ### Writing your Custom Action Your custom action can live where you choose, but simplest is to include it @@ -91,6 +95,8 @@ argument. It looks like the following: - `createTemporaryDirectory` a function to call to give you a temporary directory somewhere on the runner so you can store some files there rather than polluting the `workspacePath` +- `ctx.metadata` - an object containing a `name` field, indicating the template + name. More metadata fields may be added later. ### Registering Custom Actions @@ -113,22 +119,6 @@ return await createRouter({ There's another property you can pass here, which is an array of `actions` which will set the available actions that the scaffolder has access to. -```ts -const actions = [createNewFileAction()]; -return await createRouter({ - containerRunner, - logger, - config, - database, - catalogClient, - reader, - actions, -}); -``` - -**NOTE** - the actions array will replace the built-in actions too, so if you -want to have those as well as your new one, you'll need to do the following: - ```ts import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend'; import { ScmIntegrations } from '@backstage/integration'; @@ -144,7 +134,6 @@ const builtInActions = createBuiltinActions({ }); const actions = [...builtInActions, createNewFileAction()]; - return await createRouter({ containerRunner, logger, diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index f95177e029..81f2622312 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -284,8 +284,8 @@ to publish to. And it can be any host that is listed in your `integrations` config in `app-config.yaml`. The `RepoUrlPicker` is a custom field that we provide part of the -`plugin-scaffolder`. It's currently not possible to create your own fields yet, -but contributions are welcome! :) +`plugin-scaffolder`. You can provide your own custom fields by +[writing your own Custom Field Extensions](./writing-custom-field-extensions.md) #### The Owner Picker diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md new file mode 100644 index 0000000000..5548adf7f0 --- /dev/null +++ b/docs/features/techdocs/cli.md @@ -0,0 +1,241 @@ +# TechDocs CLI + +Utility command line interface for managing TechDocs sites in +[Backstage](https://github.com/backstage/backstage). + +https://backstage.io/docs/features/techdocs/techdocs-overview + +## Features + +- Supports local development/preview of a TechDocs site in a Backstage app. +- Supports generation and publishing of a documentation site in a CI/CD + workflow. + +```bash +techdocs-cli --help +Usage: techdocs-cli [options] [command] + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + generate|build [options] Generate TechDocs documentation site using mkdocs. + publish [options] Publish generated TechDocs site to an external storage AWS S3, + Google GCS, etc. + serve:mkdocs [options] Serve a documentation project locally using mkdocs serve. + serve [options] Serve a documentation project locally in a Backstage app-like + environment + help [command] display help for command +``` + +## Installation + +You can always use [`npx`](https://github.com/npm/npx) to run the latest version +of `techdocs-cli` - + +```bash +npx @techdocs/cli [command] +``` + +Or you can install it using [npm](https://www.npmjs.com/package/@techdocs/cli) - + +```bash +npm install -g @techdocs/cli +techdocs-cli [command] +``` + +## Usage + +### Preview TechDocs site locally in a Backstage like environment + +```bash +techdocs-cli serve +``` + +![A preview of techdocs-cli serve command](../../assets/features/techdocs/techdocs-cli-serve-preview.png) + +By default, Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) is used to +make sure all the dependencies are installed. However, Docker can be disabled +with `--no-docker` flag. + +The command starts two local servers - an MkDocs preview server on port 8000 and +a Backstage app server on port 3000. The Backstage app has a custom TechDocs API +implementation, which uses the MkDocs preview server as a proxy to fetch the +generated documentation files and assets. + +NOTE: When using a custom `techdocs` docker image, make sure the entry point is +also `ENTRYPOINT ["mkdocs"]`. + +Command reference: + +```bash +Usage: techdocs-cli serve [options] + +Serve a documentation project locally in a Backstage app-like environment + +Options: + -i, --docker-image The mkdocs docker container to use (default: "spotify/techdocs") + --no-docker Do not use Docker, use MkDocs executable in current user environment. + --mkdocs-port Port for MkDocs server to use (default: "8000") + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Generate TechDocs site from a documentation project + +```bash +techdocs-cli generate +``` + +Alias: `techdocs-cli build` + +The generate command uses the +[`@backstage/techdocs-common`](https://github.com/backstage/backstage/tree/master/packages/techdocs-common) +package from Backstage for consistency. A Backstage app can also generate and +publish TechDocs sites if `techdocs.builder` is set to `'local'` in +`app-config.yaml`. See +[configuration reference](https://backstage.io/docs/features/techdocs/configuration). + +By default, this command uses Docker and +[techdocs-container](https://github.com/backstage/techdocs-container) to make +sure all the dependencies are installed. But it can be disabled using +`--no-docker` flag. + +Command reference: + +```bash +techdocs-cli generate --help +Usage: techdocs-cli generate|build [options] + +Generate TechDocs documentation site using MkDocs. + +Options: + --source-dir Source directory containing mkdocs.yml and docs/ directory. (default: ".") + --output-dir Output directory containing generated TechDocs site. (default: "./site/") + --docker-image The mkdocs docker container to use (default: "spotify/techdocs:v0.3.4") + --no-pull Do not pull the latest docker image + --no-docker Do not use Docker, use MkDocs executable and plugins in current user environment. + --techdocs-ref The repository hosting documentation source files e.g. + github:https://ghe.mycompany.net.com/org/repo. + This value is same as the backstage.io/techdocs-ref annotation of the corresponding + Backstage entity. + It is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml + if not found. + --etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored + in techdocs_metadata.json. + -v --verbose Enable verbose output. (default: false) + -h, --help display help for command +``` + +### Publish generated TechDocs sites + +```bash +techdocs-cli publish --publisher-type --storage-name --entity +``` + +After generating a TechDocs site using `techdocs-cli generate`, use the publish +command to upload the static generated files on a cloud storage (AWS/GCS) bucket +or (Azure) container which your Backstage app can read from. + +The value for `--entity` must be the Backstage entity which the generated +TechDocs site belongs to. You can find the values in your Entity's +`catalog-info.yaml` file. If namespace is missing in the `catalog-info.yaml`, +use `default`. The directory structure used in the storage bucket is +`namespace/kind/name/`. + +Note that the values are case-sensitive. An example for `--entity` is +`default/Component/`. + +Command reference: + +```bash +Usage: techdocs-cli publish [options] + +Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc. + +Options: + --publisher-type (Required always) awsS3 | googleGcs | azureBlobStorage + - same as techdocs.publisher.type in Backstage + app-config.yaml + --storage-name (Required always) In case of AWS/GCS, use the bucket + name. In case of Azure, use container name. Same as + techdocs.publisher.[TYPE].bucketName + --entity (Required always) Entity uid separated by / in + namespace/kind/name order (case-sensitive). Example: + default/Component/myEntity + --legacyUseCaseSensitiveTripletPaths Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). + Only use if your TechDocs backend is configured the same way + --azureAccountName (Required for Azure) specify when --publisher-type + azureBlobStorage + --azureAccountKey Azure Storage Account key to use for authentication. + If not specified, you must set AZURE_TENANT_ID, + AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment + variables. + --awsRoleArn Optional AWS ARN of role to be assumed. + --awsEndpoint Optional AWS endpoint to send requests to. + --awsS3ForcePathStyle Optional AWS S3 option to force path style. + --directory Path of the directory containing generated files to + publish (default: "./site/") + -h, --help display help for command +``` + +### Migrate content for case-insensitive access + +Prior to the beta version of TechDocs (`v[0.11.0]`), TechDocs were stored in +object storage using a case-sensitive entity triplet (e.g. +`default/API/name/index.html`). This resulted in a limitation where that exact +case was required in the Backstage URL in order to read/render TechDocs content. +As of `v[0.11.0]` of the TechDocs plugin, any case is allowed in the URL (e.g. +`default/api/name`), matching the behavior of the Catalog plugin. + +Backstage instances created with TechDocs `v[0.11.0]` or later do not need this +command. However, when upgrading to this version from an older version of +TechDocs, the `migrate` command can be used prior to deployment to ensure docs +remain accessible without having to rebuild all docs. + +Prior to upgrading to `v[0.11.0]` or greater, run this command to copy all +assets to their lower-case triplet equivalents like this: + +```bash +techdocs-cli migrate --publisher-type --storage-name --verbose +``` + +Once migrated and the upgraded version of the Backstage plugin has been +deployed, you can clean up the legacy, case-sensitive triplet files by +re-running the command with the `--removeOriginal` flag passed, which _moves_ +(rather than copies) the files. Note: this deletes files and is therefore a +destructive operation that should performed with caution. + +```bash +techdocs-cli migrate --publisher-type --storage-name --removeOriginal --verbose +``` + +Afterward, update your TechDocs CLI to `v[0.7.0]` to ensure further publishing +happens using a lower-case entity triplet. + +Note: arguments for this command largely match those of the `publish` command, +depending on your chosen storage provider. Run `techdocs-cli migrate --help` for +details. + +#### Authentication + +You need to make sure that your environment is able to authenticate with the +target cloud provider. `techdocs-cli` uses the official Node.js clients provided +by AWS (v2), Google Cloud and Azure. You can authenticate using environment +variables and/or by other means (`~/.aws/credentials`, `~/.config/gcloud` etc.) + +Refer to the Authentication section of the following documentation depending +upon your cloud storage provider - + +- [Google Cloud Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-google-gcs-bucket-with-techdocs) +- [AWS S3](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs) +- [Azure Blob Storage](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-azure-blob-storage-container-with-techdocs) + +## Development + +You are welcome to contribute to TechDocs CLI to improve it and support new +features! See the project +[README](https://github.com/backstage/backstage/blob/main/src/packages/techdocs-cli/README.md) +for more information. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index fbe9f36540..61d95f55c0 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -106,6 +106,12 @@ techdocs: # This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. s3ForcePathStyle: false + # (Optional) AWS Server Side Encryption + # Defaults to undefined. + # If not set, encrypted buckets will fail to publish. + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + sse: 'aws:kms' # or AES256 + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. azureBlobStorage: diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 0b14a2e136..18fdef892a 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -41,6 +41,7 @@ lint Lint a package test Run tests, forwarding args to Jest, defaulting to watch mode clean Delete cache directories +create Open up an interactive guide to creating new things in your app create-plugin Creates a new plugin in the current repository remove-plugin Removes plugin in the current repository @@ -277,6 +278,44 @@ Options: -h, --help display help for command ``` +## create + +Scope: `root` + +The `create` command opens up an interactive guide for you to create new things +in your app. If you do not pass in any options it is completely interactive, but +it is possible to pre-select what you want to create using the `--select` flag, +and provide options using `--options`, for example: + +```bash +backstage-cli create --select plugin --option id=foo +``` + +This command is typically added as script in the root `package.json` to be +executed with `yarn backstage-create`, using options that are appropriate for +the organization that owns the app repo. For example you may have it set up like +this: + +```json +{ + "scripts": { + "backstage-create": "backstage-cli create --scope internal --no-private --npm-registry https://acme.org/npm" + } +} +``` + +```text +Usage: backstage-cli create [options] + +Options: + --select Select the thing you want to be creating upfront + --option = Pre-fill options for the creation process (default: []) + --scope The scope to use for new packages + --npm-registry The package registry to use for new packages + --no-private Do not mark new packages as private + -h, --help display help for command +``` + ## create-plugin Scope: `root` diff --git a/microsite/data/plugins/splunk-on-call.yaml b/microsite/data/plugins/splunk-on-call.yaml index 15ceb86a1b..bb0e25111a 100644 --- a/microsite/data/plugins/splunk-on-call.yaml +++ b/microsite/data/plugins/splunk-on-call.yaml @@ -1,7 +1,7 @@ --- title: Splunk On-Call -author: Spotify -authorUrl: https://github.com/spotify +author: ayshiff +authorUrl: https://github.com/ayshiff category: Monitoring description: Splunk On-Call offers a simple way to identify incidents and escalation policies. documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call diff --git a/microsite/sidebars.json b/microsite/sidebars.json index ec5c5d8d68..a24d5897ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -105,6 +105,7 @@ "features/techdocs/configuration", "features/techdocs/using-cloud-storage", "features/techdocs/configuring-ci-cd", + "features/techdocs/cli", "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" diff --git a/microsite/yarn.lock b/microsite/yarn.lock index f3834ac734..c03ecf989a 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -1624,15 +1624,6 @@ cli-width@^2.0.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== -clipboard@^2.0.0: - version "2.0.6" - resolved "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376" - integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg== - dependencies: - good-listener "^1.2.2" - select "^1.1.2" - tiny-emitter "^2.0.0" - clone-response@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" @@ -1687,9 +1678,9 @@ color-name@^1.0.0, color-name@~1.1.4: integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== color-string@^1.5.4: - version "1.5.4" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" - integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + version "1.6.0" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" + integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -2158,11 +2149,6 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegate@^3.1.2: - version "3.2.0" - resolved "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" - integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== - depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3097,13 +3083,6 @@ globule@^1.0.0: lodash "~4.17.10" minimatch "~3.0.2" -good-listener@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" - integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= - dependencies: - delegate "^3.1.2" - got@^7.0.0: version "7.1.0" resolved "https://registry.npmjs.org/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" @@ -3276,9 +3255,9 @@ highlight.js@^9.16.2: integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hsl-regex@^1.0.0: version "1.0.0" @@ -4815,9 +4794,9 @@ path-key@^2.0.0, path-key@^2.0.1: integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + version "1.0.7" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@0.1.7: version "0.1.7" @@ -4850,6 +4829,11 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" + integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== + pify@^2.0.0, pify@^2.2.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -5189,13 +5173,12 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.23, postcss@^7.0.27, postcss@^7.0.32: - version "7.0.35" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" - integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + version "7.0.39" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" + integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== dependencies: - chalk "^2.4.2" + picocolors "^0.2.1" source-map "^0.6.1" - supports-color "^6.1.0" prepend-http@^1.0.1: version "1.0.4" @@ -5213,11 +5196,9 @@ prettier@^2.4.1: integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== prismjs@^1.22.0: - version "1.23.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" - integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== - optionalDependencies: - clipboard "^2.0.0" + version "1.25.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@~2.0.0: version "2.0.1" @@ -5709,11 +5690,6 @@ seek-bzip@^1.0.5: dependencies: commander "^2.8.1" -select@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" - integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= - semver-regex@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" @@ -5766,9 +5742,9 @@ serve-static@1.14.1: send "0.17.1" set-getter@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" - integrity sha1-12nBgsnVpR9AkUXy+6guXoboA3Y= + version "0.1.1" + resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102" + integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw== dependencies: to-object-path "^0.3.0" @@ -6150,13 +6126,6 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -6250,11 +6219,6 @@ timsort@^0.3.0: resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tiny-emitter@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-lr@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" @@ -6502,9 +6466,9 @@ url-parse-lax@^3.0.0: prepend-http "^2.0.0" url-parse@^1.4.3: - version "1.4.7" - resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" - integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + version "1.5.3" + resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" diff --git a/mkdocs.yml b/mkdocs.yml index 8c7114abbc..48df25afef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,7 +61,6 @@ nav: - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Templates (Legacy): 'features/software-templates/legacy.md' - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' - - Backstage Search: - Overview: 'features/search/README.md' - Getting Started: 'features/search/getting-started.md' @@ -78,6 +77,7 @@ nav: - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' + - CLI: 'features/techdocs/cli.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' diff --git a/package.json b/package.json index 3e6d376c46..16ba8461ac 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image", - "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", + "backstage-create": "backstage-cli create --scope backstage --no-private", + "create-plugin": "yarn backstage-create --select plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install", "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook", + "techdocs-cli": "node scripts/techdocs-cli.js", + "techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js", "prepare": "husky install", "lock:check": "yarn-lock-check" }, @@ -50,7 +53,7 @@ }, "version": "1.0.0", "dependencies": { - "@microsoft/api-documenter": "^7.13.47", + "@microsoft/api-documenter": "^7.13.68", "@microsoft/api-extractor": "^7.18.7", "@microsoft/api-extractor-model": "^7.13.5", "@microsoft/tsdoc": "^0.13.2" @@ -61,6 +64,7 @@ "@spotify/prettier-config": "^11.0.0", "@types/webpack": "^5.28.0", "command-exists": "^1.2.9", + "cross-env": "^7.0.0", "concurrently": "^6.0.0", "eslint-plugin-notice": "^0.9.10", "fs-extra": "9.1.0", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3fe027ddad..3b9b381380 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -35,6 +35,7 @@ import { } from '@backstage/plugin-api-docs'; import { EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, } from '@backstage/plugin-azure-devops'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; @@ -264,6 +265,18 @@ const errorsContent = ( ); +const pullRequestsContent = ( + + + + + + + + + +); + const overviewContent = ( {entityWarningContent} @@ -368,7 +381,7 @@ const serviceEntityPage = ( - + {pullRequestsContent} @@ -427,7 +440,7 @@ const websiteEntityPage = ( - + {pullRequestsContent} diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index a19447034e..e3119e78a8 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -6,6 +6,7 @@ /// /// +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; import { AwsS3Integration } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; @@ -48,7 +49,7 @@ export class AwsS3UrlReader implements UrlReader { // (undocumented) read(url: string): Promise; // (undocumented) - readTree(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) @@ -72,7 +73,7 @@ export class AzureUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -94,7 +95,7 @@ export class BitbucketUrlReader implements UrlReader { // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; // (undocumented) - readUrl(url: string, _options?: ReadUrlOptions): Promise; + readUrl(url: string, options?: ReadUrlOptions): Promise; // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) @@ -374,6 +375,9 @@ export class GitlabUrlReader implements UrlReader { export { isChildPath }; +// @public +export function isDatabaseConflictError(e: unknown): boolean; + // @public export function loadBackendConfig(options: { logger: Logger_2; @@ -415,6 +419,7 @@ export type ReadTreeOptions = { }, ): boolean; etag?: string; + signal?: AbortSignal_2; }; // @public @@ -468,6 +473,7 @@ export type ReadTreeResponseFile = { // @public export type ReadUrlOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public @@ -505,6 +511,7 @@ export type RunContainerOptions = { // @public export type SearchOptions = { etag?: string; + signal?: AbortSignal_2; }; // @public diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index bc5e026375..3690069431 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -69,6 +69,19 @@ export interface Config { * Defaults to true if unspecified. */ ensureExists?: boolean; + /** + * How plugins databases are managed/divided in the provided database instance. + * + * `database` -> Plugins are each given their own database to manage their schemas/tables. + * + * `schema` -> Plugins will be given their own schema (in the specified/default database) + * to manage their tables. + * + * NOTE: Currently only supported by the `pg` client. + * + * @default database + */ + pluginDivisionMode?: 'database' | 'schema'; /** Plugin specific database configuration and client override */ plugin?: { [pluginId: string]: { @@ -130,6 +143,14 @@ export interface Config { * The host may also contain a port, for example `example.com:8080`. */ host: string; + + /** + * An optional list of paths. In case they are present only targets matching + * any of them will are allowed. You can use trailing slashes to make sure only + * subdirectories are allowed, for example `/mydir/` will allow targets with + * paths like `/mydir/a` but will block paths like `/mydir2`. + */ + paths?: string[]; }>; }; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 03ae1fbe62..16ce62c7d7 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -62,6 +62,7 @@ "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", + "node-abort-controller": "^3.0.1", "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 3fd5e1b44e..941ad26e65 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -200,7 +200,7 @@ export async function loadBackendConfig(options: { }); const config = new ObservableConfigProxy(options.logger); - const configs = await loadConfig({ + const { appConfigs } = await loadConfig({ configRoot: paths.targetRoot, configPaths: [], configTargets: configTargets, @@ -227,14 +227,16 @@ export async function loadBackendConfig(options: { }); options.logger.info( - `Loaded config from ${configs.map(c => c.context).join(', ')}`, + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, ); - config.setConfig(ConfigReader.fromConfigs(configs)); + config.setConfig(ConfigReader.fromConfigs(appConfigs)); // Subscribe to config changes and update the redaction list for logging - updateRedactionList(schema, configs, options.logger); - config.subscribe(() => updateRedactionList(schema, configs, options.logger)); + updateRedactionList(schema, appConfigs, options.logger); + config.subscribe(() => + updateRedactionList(schema, appConfigs, options.logger), + ); return config; } diff --git a/packages/backend-common/src/database/DatabaseManager.test.ts b/packages/backend-common/src/database/DatabaseManager.test.ts index e839123908..f2fe855234 100644 --- a/packages/backend-common/src/database/DatabaseManager.test.ts +++ b/packages/backend-common/src/database/DatabaseManager.test.ts @@ -15,13 +15,18 @@ */ import { ConfigReader } from '@backstage/config'; import { omit } from 'lodash'; -import { createDatabaseClient, ensureDatabaseExists } from './connection'; +import { + createDatabaseClient, + ensureDatabaseExists, + ensureSchemaExists, +} from './connection'; import { DatabaseManager } from './DatabaseManager'; jest.mock('./connection', () => ({ ...jest.requireActual('./connection'), createDatabaseClient: jest.fn(), ensureDatabaseExists: jest.fn(), + ensureSchemaExists: jest.fn(), })); describe('DatabaseManager', () => { @@ -314,5 +319,191 @@ describe('DatabaseManager', () => { expect.stringContaining('userdbname'), ); }); + + it('plugin sets schema override for pg client', async () => { + const overrideConfig = { + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }; + const testManager = DatabaseManager.fromConfig( + new ConfigReader(overrideConfig), + ); + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: config.backend.database.connection, + }); + + expect(overrides).toMatchObject({ + searchPath: [pluginId], + }); + }); + + it('plugin does not provide schema override for non pg client', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'sqlite3', + connection: config.backend.database.connection, + }); + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is set to database', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'database', + connection: 'some-file-path', + }, + }, + }), + ); + + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is not set', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('pluginDivisionMode ensures that each plugin schema exists', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(ensureSchemaExists).mock.calls; + const [_, schemaName] = mockCalls[0]; + + expect(schemaName).toEqual('testdbname'); + }); + + it('pluginDivisionMode allows connection overrides for plugins', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + plugin: { + testdbname: { + connection: { + database: 'database_name_overriden', + host: 'newhost', + }, + }, + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: { + database: 'database_name_overriden', + host: 'newhost', + user: 'foo', + password: 'bar', + }, + }); + expect(overrides).toHaveProperty('searchPath', ['testdbname']); + expect(overrides).toHaveProperty( + 'connection.database', + 'database_name_overriden', + ); + }); }); }); diff --git a/packages/backend-common/src/database/DatabaseManager.ts b/packages/backend-common/src/database/DatabaseManager.ts index c0511c6b78..cf5e801d66 100644 --- a/packages/backend-common/src/database/DatabaseManager.ts +++ b/packages/backend-common/src/database/DatabaseManager.ts @@ -13,17 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Knex } from 'knex'; import { omit } from 'lodash'; import { Config, ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { - createDatabaseClient, - ensureDatabaseExists, createNameOverride, + ensureDatabaseExists, normalizeConnection, + createSchemaOverride, + ensureSchemaExists, + createDatabaseClient, } from './connection'; import { PluginDatabaseManager } from './types'; +import { mergeDatabaseConfig } from './config'; /** * Provides a config lookup path for a plugin's config block. @@ -79,14 +83,15 @@ export class DatabaseManager { * Provides the canonical database name for a given plugin. * * This method provides the effective database name which is determined using global - * and plugin specific database config. If no explicit database name is configured, - * this method will provide a generated name which is the pluginId prefixed with - * 'backstage_plugin_'. + * and plugin specific database config. If no explicit database name is configured + * and `pluginDivisionMode` is not `schema`, this method will provide a generated name + * which is the pluginId prefixed with 'backstage_plugin_'. If `pluginDivisionMode` is + * `schema`, it will fallback to using the default database for the knex instance. * * @param pluginId Lookup the database name for given plugin * @returns String representing the plugin's database name */ - private getDatabaseName(pluginId: string): string { + private getDatabaseName(pluginId: string): string | undefined { const connection = this.getConnectionConfig(pluginId); if (this.getClientType(pluginId).client === 'sqlite3') { @@ -95,11 +100,16 @@ export class DatabaseManager { (connection as Knex.Sqlite3ConnectionConfig)?.filename ?? ':memory:' ); } + + const databaseName = (connection as Knex.ConnectionConfig)?.database; + + // `pluginDivisionMode` as `schema` should use overridden databaseName if supplied or fallback to default knex database + if (this.getPluginDivisionModeConfig() === 'schema') { + return databaseName; + } + // all other supported databases should fallback to an auto-prefixed name - return ( - (connection as Knex.ConnectionConfig)?.database ?? - `${this.prefix}${pluginId}` - ); + return databaseName ?? `${this.prefix}${pluginId}`; } /** @@ -136,13 +146,18 @@ export class DatabaseManager { ); } + private getPluginDivisionModeConfig(): string { + return this.config.getOptionalString('pluginDivisionMode') ?? 'database'; + } + /** * Provides a Knex connection plugin config by combining base and plugin config. * * This method provides a baseConfig for a plugin database connector. If the client type * has not been overridden, the global connection config will be included with plugin * specific config as the base. Values from the plugin connection take precedence over the - * base. Base database name is omitted for all supported databases excluding SQLite. + * base. Base database name is omitted for all supported databases excluding SQLite unless + * `pluginDivisionMode` is set to `schema`. */ private getConnectionConfig( pluginId: string, @@ -153,10 +168,13 @@ export class DatabaseManager { this.config.get('connection'), this.config.getString('client'), ); - // As databases cannot be shared, the `database` property from the base connection - // is omitted. SQLite3's `filename` property is an exception as this is used as a + // Databases cannot be shared unless the `pluginDivisionMode` is set to `schema`. The + // `database` property from the base connection is omitted unless `pluginDivisionMode` + // is set to `schema`. SQLite3's `filename` property is an exception as this is used as a // directory elsewhere so we preserve `filename`. - baseConnection = omit(baseConnection, 'database'); + if (this.getPluginDivisionModeConfig() !== 'schema') { + baseConnection = omit(baseConnection, 'database'); + } // get and normalize optional plugin specific database connection const connection = normalizeConnection( @@ -165,7 +183,7 @@ export class DatabaseManager { ); return { - // include base connection if client type has not been overriden + // include base connection if client type has not been overridden ...(overridden ? {} : baseConnection), ...connection, }; @@ -187,6 +205,16 @@ export class DatabaseManager { }; } + /** + * Provides a partial Knex.Config database schema override for a given plugin. + * + * @param pluginId Target plugin to get database schema override + * @returns Partial Knex.Config with database schema override + */ + private getSchemaOverrides(pluginId: string): Knex.Config | undefined { + return createSchemaOverride(this.getClientType(pluginId).client, pluginId); + } + /** * Provides a partial Knex.Config database name override for a given plugin. * @@ -194,10 +222,10 @@ export class DatabaseManager { * @returns Partial Knex.Config with database name override */ private getDatabaseOverrides(pluginId: string): Knex.Config { - return createNameOverride( - this.getClientType(pluginId).client, - this.getDatabaseName(pluginId), - ); + const databaseName = this.getDatabaseName(pluginId); + return databaseName + ? createNameOverride(this.getClientType(pluginId).client, databaseName) + : {}; } /** @@ -211,8 +239,8 @@ export class DatabaseManager { this.getConfigForPlugin(pluginId) as JsonObject, ); - if (this.getEnsureExistsConfig(pluginId)) { - const databaseName = this.getDatabaseName(pluginId); + const databaseName = this.getDatabaseName(pluginId); + if (databaseName && this.getEnsureExistsConfig(pluginId)) { try { await ensureDatabaseExists(pluginConfig, databaseName); } catch (error) { @@ -222,9 +250,24 @@ export class DatabaseManager { } } - return createDatabaseClient( - pluginConfig, + let schemaOverrides; + if (this.getPluginDivisionModeConfig() === 'schema') { + try { + schemaOverrides = this.getSchemaOverrides(pluginId); + await ensureSchemaExists(pluginConfig, pluginId); + } catch (error) { + throw new Error( + `Failed to connect to the database to make sure that schema for plugin '${pluginId}' exists, ${error}`, + ); + } + } + + const databaseClientOverrides = mergeDatabaseConfig( + {}, this.getDatabaseOverrides(pluginId), + schemaOverrides, ); + + return createDatabaseClient(pluginConfig, databaseClientOverrides); } } diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 869722ddf7..6f02e7b2ed 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -18,8 +18,24 @@ import { ConfigReader } from '@backstage/config'; import { createDatabaseClient, createNameOverride, + createSchemaOverride, + ensureSchemaExists, parseConnectionString, } from './connection'; +import { pgConnector } from './connectors'; + +const mocked = (f: Function) => f as jest.Mock; + +jest.mock('./connectors', () => { + const connectors = jest.requireActual('./connectors'); + return { + ...connectors, + pgConnector: { + ...connectors.pgConnector, + ensureSchemaExists: jest.fn(), + }, + }; +}); describe('database connection', () => { describe('createDatabaseClient', () => { @@ -152,4 +168,63 @@ describe('database connection', () => { expect(() => parseConnectionString('sqlite://')).toThrow(); }); }); + + describe('createSchemaOverride', () => { + it('returns Knex config for postgres', () => { + expect(createSchemaOverride('pg', 'testpg')).toHaveProperty( + 'searchPath', + ['testpg'], + ); + }); + + it('throws error for sqlite', () => { + expect(createSchemaOverride('sqlite3', 'testsqlite')).toBeUndefined(); + }); + + it('returns Knex config for mysql', () => { + expect(createSchemaOverride('mysql', 'testmysql')).toBeUndefined(); + }); + + it('throws an error for unknown connection', () => { + expect(createSchemaOverride('unknown', 'testname')).toBeUndefined(); + }); + }); + + describe('ensureSchemaExists', () => { + it('returns sucessfully with pg client', async () => { + await ensureSchemaExists( + new ConfigReader({ + client: 'pg', + schema: 'catalog', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }), + 'catalog', + ); + + const mockCalls = mocked( + pgConnector.ensureSchemaExists as Function, + ).mock.calls.splice(-1); + const [baseConfig, schemaName] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }); + + expect(schemaName).toEqual('catalog'); + }); + + it('throws error for non pg client', () => { + return expect( + ensureSchemaExists( + new ConfigReader({ + client: 'sqlite3', + schema: 'catalog', + connection: ':memory:', + }), + 'catalog', + ), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 1c5cce3718..3308a26573 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -82,6 +82,23 @@ export async function ensureDatabaseExists( ); } +/** + * Ensures that the given schemas all exist, creating them if they do not. + * + * @public + */ +export async function ensureSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const client: DatabaseClient = dbConfig.getString('client'); + + return await ConnectorMapping[client]?.ensureSchemaExists?.( + dbConfig, + ...schemas, + ); +} + /** * Provides a Knex.Config object with the provided database name for a given client. */ @@ -99,6 +116,23 @@ export function createNameOverride( } } +/** + * Provides a Knex.Config object with the provided database schema for a given client. Currently only supported by `pg`. + */ +export function createSchemaOverride( + client: string, + name: string, +): Partial { + try { + return ConnectorMapping[client]?.createSchemaOverride?.(name); + } catch (e) { + throw new InputError( + `Unable to create database schema override for '${client}' connector`, + e, + ); + } +} + /** * Parses a connection string for a given client and provides a connection config. */ diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts new file mode 100644 index 0000000000..2db5ecd454 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts @@ -0,0 +1,25 @@ +/* + * 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 defaultSchemaOverride from './defaultSchemaOverride'; + +describe('defaultNameOverride()', () => { + it('returns a partial knex static connection config with searchPath set to [schemaName]', () => { + const schemaName = 'schemaName'; + expect(defaultSchemaOverride(schemaName)).toHaveProperty('searchPath', [ + schemaName, + ]); + }); +}); diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts new file mode 100644 index 0000000000..62ad8ab8c8 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts @@ -0,0 +1,29 @@ +/* + * 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 { Knex } from 'knex'; + +/** + * Provides a partial knex config with schema name override. + * + * @param name schema name to get config override for + */ +export default function defaultSchemaOverride( + name: string, +): Partial { + return { + searchPath: [name], + }; +} diff --git a/packages/backend-common/src/database/connectors/postgres.test.ts b/packages/backend-common/src/database/connectors/postgres.test.ts index ac988a2880..553d019573 100644 --- a/packages/backend-common/src/database/connectors/postgres.test.ts +++ b/packages/backend-common/src/database/connectors/postgres.test.ts @@ -76,6 +76,24 @@ describe('postgres', () => { }); }); + it('overrides the schema name', () => { + const mockConnection = { + ...createMockConnection(), + schema: 'schemaName', + }; + + expect( + buildPgDatabaseConfig(createConfig(mockConnection), { + searchPath: ['schemaName'], + }), + ).toEqual({ + client: 'pg', + connection: mockConnection, + searchPath: ['schemaName'], + useNullAsDefault: true, + }); + }); + it('adds additional config settings', () => { const mockConnection = createMockConnection(); diff --git a/packages/backend-common/src/database/connectors/postgres.ts b/packages/backend-common/src/database/connectors/postgres.ts index 62de2e001e..823d50f73f 100644 --- a/packages/backend-common/src/database/connectors/postgres.ts +++ b/packages/backend-common/src/database/connectors/postgres.ts @@ -21,6 +21,7 @@ import { ForwardedError } from '@backstage/errors'; import { mergeDatabaseConfig } from '../config'; import { DatabaseConnector } from '../types'; import defaultNameOverride from './defaultNameOverride'; +import defaultSchemaOverride from './defaultSchemaOverride'; /** * Creates a knex postgres database connection @@ -135,6 +136,29 @@ export async function ensurePgDatabaseExists( } } +/** + * Creates the missing Postgres schema if it does not exist + * + * @param dbConfig The database config + * @param schemas The name of the schemas to create + */ +export async function ensurePgSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const admin = createPgDatabaseClient(dbConfig); + + try { + const ensureSchema = async (database: string) => { + await admin.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [database]); + }; + + await Promise.all(schemas.map(ensureSchema)); + } finally { + await admin.destroy(); + } +} + /** * PostgreSQL database connector. * @@ -143,6 +167,8 @@ export async function ensurePgDatabaseExists( export const pgConnector: DatabaseConnector = Object.freeze({ createClient: createPgDatabaseClient, ensureDatabaseExists: ensurePgDatabaseExists, + ensureSchemaExists: ensurePgSchemaExists, createNameOverride: defaultNameOverride, + createSchemaOverride: defaultSchemaOverride, parseConnectionString: parsePgConnectionString, }); diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index 7fcb8bf930..2a820f7367 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -28,3 +28,4 @@ export { } from './connection'; export type { PluginDatabaseManager } from './types'; +export { isDatabaseConflictError } from './util'; diff --git a/packages/backend-common/src/database/types.ts b/packages/backend-common/src/database/types.ts index edcba1e643..e96f86980b 100644 --- a/packages/backend-common/src/database/types.ts +++ b/packages/backend-common/src/database/types.ts @@ -45,6 +45,11 @@ export interface DatabaseConnector { * database name. */ createNameOverride(name: string): Partial; + /** + * createSchemaOverride provides a partial knex config sufficient to override a + * PostgreSQL schema name within utilizing the `searchPath` knex configuration. + */ + createSchemaOverride?(name: string): Partial; /** * parseConnectionString produces a knex connection config object representing * a database connection string. @@ -64,4 +69,16 @@ export interface DatabaseConnector { dbConfig: Config, ...databases: Array ): Promise; + + /** + * ensureSchemaExists performs a side-effect to ensure schema names passed in are + * present. + * + * Calling this function on schemas which already exist should do nothing. + * Missing schemas should be created if needed. + */ + ensureSchemaExists?( + dbConfig: Config, + ...schemas: Array + ): Promise; } diff --git a/packages/backend-common/src/database/util.ts b/packages/backend-common/src/database/util.ts new file mode 100644 index 0000000000..607554f294 --- /dev/null +++ b/packages/backend-common/src/database/util.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. + */ + +/** + * Tries to deduce whether a thrown error is a database conflict. + * + * @public + * @param e - A thrown error + * @returns True if the error looks like it was a conflict error thrown by a + * known database engine + */ +export function isDatabaseConflictError(e: unknown) { + const message = (e as any)?.message; + + return ( + typeof message === 'string' && + (/SQLITE_CONSTRAINT: UNIQUE/.test(message) || + /unique constraint/.test(message)) + ); +} diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 192decb653..0a664f9656 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -48,6 +48,19 @@ describe('rootLogger', () => { ); }); + it('redacts but ignores empty and one-character secrets', () => { + const logger = createRootLogger(); + jest.spyOn(logger, 'write'); + setRootLoggerRedactionList(['SECRET-1', 'SECRET_2', 'Q', '']); + logger.info('Logging SECRET-1 and SECRET_2 and Q'); + + expect(logger.write).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Logging [REDACTED] and [REDACTED] and Q', + }), + ); + }); + describe('createRootLogger', () => { it('creates a new logger', () => { const oldLogger = getRootLogger(); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index b05763a42c..2a4226f88f 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -21,7 +21,7 @@ import { coloredFormat } from './formats'; import { escapeRegExp } from '../util/escapeRegExp'; let rootLogger: winston.Logger; -let redactionRegExp: RegExp; +let redactionRegExp: RegExp | undefined; /** @public */ export function getRootLogger(): winston.Logger { @@ -34,11 +34,18 @@ export function setRootLogger(newLogger: winston.Logger) { } export function setRootLoggerRedactionList(redactionList: string[]) { - if (redactionList.length) { + // Exclude secrets that are empty or just one character in length. These + // typically mean that you are running local dev or tests, or using the + // --lax flag which sets things to just 'x'. So exclude those. + const filtered = redactionList.filter(r => r.length > 1); + + if (filtered.length) { redactionRegExp = new RegExp( - `(${redactionList.map(escapeRegExp).join('|')})`, + `(${filtered.map(escapeRegExp).join('|')})`, 'g', ); + } else { + redactionRegExp = undefined; } } diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.ts b/packages/backend-common/src/reading/AwsS3UrlReader.ts index 05a14e26ec..0d806fcfaa 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.ts @@ -18,6 +18,7 @@ import aws, { Credentials, S3 } from 'aws-sdk'; import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import { ReaderFactory, + ReadTreeOptions, ReadTreeResponse, ReadTreeResponseFactory, ReadUrlOptions, @@ -92,7 +93,7 @@ export class AwsS3UrlReader implements UrlReader { ) {} /** - * If accesKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: + * If accessKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html */ private static buildCredentials( @@ -154,9 +155,10 @@ export class AwsS3UrlReader implements UrlReader { }; } - const response = this.deps.s3.getObject(params); - const buffer = await getRawBody(response.createReadStream()); - const etag = (await response.promise()).ETag; + const request = this.deps.s3.getObject(params); + options?.signal?.addEventListener('abort', () => request.abort()); + const buffer = await getRawBody(request.createReadStream()); + const etag = (await request.promise()).ETag; return { buffer: async () => buffer, @@ -171,7 +173,10 @@ export class AwsS3UrlReader implements UrlReader { } } - async readTree(url: string): Promise { + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { try { const { path, bucket, region } = parseURL(url); const allObjects: ObjectList = []; @@ -180,13 +185,13 @@ export class AwsS3UrlReader implements UrlReader { let output: ListObjectsV2Output; do { aws.config.update({ region: region }); - output = await this.deps.s3 - .listObjectsV2({ - Bucket: bucket, - ContinuationToken: continuationToken, - Prefix: path, - }) - .promise(); + const request = this.deps.s3.listObjectsV2({ + Bucket: bucket, + ContinuationToken: continuationToken, + Prefix: path, + }); + options?.signal?.addEventListener('abort', () => request.abort()); + output = await request.promise(); if (output.Contents) { output.Contents.forEach(contents => { allObjects.push(contents); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 6015b044bc..82d1192b17 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -55,21 +55,34 @@ export class AzureUrlReader implements UrlReader { ) {} async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not implemented yet. + const { signal } = options ?? {}; + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch( - builtUrl, - getAzureRequestOptions(this.integration.config), - ); + response = await fetch(builtUrl, { + ...getAzureRequestOptions(this.integration.config), + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -79,19 +92,12 @@ 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, ): Promise { + const { etag, filter, signal } = options ?? {}; + // TODO: Support filepath based reading tree feature like other providers // Get latest commit SHA @@ -109,16 +115,16 @@ export class AzureUrlReader implements UrlReader { } const commitSha = (await commitsAzureResponse.json()).value[0].commitId; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } - const archiveAzureResponse = await fetch( - getAzureDownloadUrl(url), - getAzureRequestOptions(this.integration.config, { + const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), { + ...getAzureRequestOptions(this.integration.config, { Accept: 'application/zip', }), - ); + ...(signal && { signal }), + }); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; if (archiveAzureResponse.status === 404) { @@ -139,7 +145,7 @@ export class AzureUrlReader implements UrlReader { return await this.deps.treeResponseFactory.fromZipArchive({ stream: archiveAzureResponse.body as unknown as Readable, etag: commitSha, - filter: options?.filter, + filter, subpath, }); } @@ -158,6 +164,7 @@ export class AzureUrlReader implements UrlReader { const tree = await this.readTree(treeUrl.toString(), { etag: options?.etag, + signal: options?.signal, filter: p => (matcher ? matcher.match(p) : true), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 4ae0ea37a8..c1a113a50d 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -77,18 +77,33 @@ export class BitbucketUrlReader implements UrlReader { } async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // TODO: etag is not supported yet + const { signal } = options ?? {}; const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config); - const options = getBitbucketRequestOptions(this.integration.config); + const requestOptions = getBitbucketRequestOptions(this.integration.config); let response: Response; try { - response = await fetch(bitbucketUrl.toString(), options); + response = await fetch(bitbucketUrl.toString(), { + ...requestOptions, + ...(signal && { signal }), + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } if (response.ok) { - return Buffer.from(await response.arrayBuffer()); + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + }; } const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; @@ -98,15 +113,6 @@ 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.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 124abb799c..9d6edee765 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -77,6 +77,10 @@ describe('FetchUrlReader', () => { { host: 'example.com:700' }, { host: '*.examples.org' }, { host: '*.examples.org:700' }, + { + host: 'foobar.org', + paths: ['/dir1/'], + }, ], }, }, @@ -106,6 +110,9 @@ describe('FetchUrlReader', () => { expect(predicate(new URL('https://examples.org:700/test'))).toBe(false); expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true); expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir1/subpath'))).toBe(true); + expect(predicate(new URL('https://foobar.org/dir12'))).toBe(false); + expect(predicate(new URL('https://foobar.org/'))).toBe(false); }); describe('read', () => { diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 732d3b9f59..d82b1f7f52 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -24,6 +24,7 @@ import { SearchResponse, UrlReader, } from './types'; +import path from 'path'; /** * A UrlReader that does a plain fetch of the URL. @@ -39,18 +40,30 @@ export class FetchUrlReader implements UrlReader { * `host`: * Either full hostnames to match, or subdomain wildcard matchers with a leading `*`. * For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not. + * + * `paths`: + * An optional list of paths which are allowed. If the list is omitted all paths are allowed. */ static factory: ReaderFactory = ({ config }) => { const predicates = config .getOptionalConfigArray('backend.reading.allow') ?.map(allowConfig => { + const paths = allowConfig.getOptionalStringArray('paths'); + const checkPath = paths + ? (url: URL) => { + const targetPath = path.posix.normalize(url.pathname); + return paths.some(allowedPath => + targetPath.startsWith(allowedPath), + ); + } + : (_url: URL) => true; const host = allowConfig.getString('host'); if (host.startsWith('*.')) { const suffix = host.slice(1); - return (url: URL) => url.host.endsWith(suffix); + return (url: URL) => url.host.endsWith(suffix) && checkPath(url); } - return (url: URL) => url.host === host; + return (url: URL) => url.host === host && checkPath(url); }) ?? []; const reader = new FetchUrlReader(); @@ -73,6 +86,7 @@ export class FetchUrlReader implements UrlReader { headers: { ...(options?.etag && { 'If-None-Match': options.etag }), }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index ca16e23fec..755e0f17e6 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -110,6 +110,7 @@ export class GithubUrlReader implements UrlReader { ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, + signal: options?.signal, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -164,7 +165,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, options, ); } @@ -188,7 +189,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers }, + { headers, signal: options?.signal }, ); return { files, etag: commitSha }; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 3fc9673e8b..128aed3c85 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -66,6 +66,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadUrlOptions, ): Promise { + const { etag, signal } = options ?? {}; const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; @@ -73,8 +74,9 @@ export class GitlabUrlReader implements UrlReader { response = await fetch(builtUrl, { headers: { ...getGitLabRequestOptions(this.integration.config).headers, - ...(options?.etag && { 'If-None-Match': options.etag }), + ...(etag && { 'If-None-Match': etag }), }, + ...(signal && { signal }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -102,6 +104,7 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { + const { etag, signal } = options ?? {}; const { ref, full_name, filepath } = parseGitUrl(url); // Use GitLab API to get the default branch @@ -140,7 +143,10 @@ export class GitlabUrlReader implements UrlReader { full_name, )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!commitsGitlabResponse.ok) { const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; @@ -152,7 +158,7 @@ export class GitlabUrlReader implements UrlReader { const commitSha = (await commitsGitlabResponse.json())[0].id; - if (options?.etag && options.etag === commitSha) { + if (etag && etag === commitSha) { throw new NotModifiedError(); } @@ -161,7 +167,10 @@ export class GitlabUrlReader implements UrlReader { `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, )}/repository/archive?sha=${branch}`, - getGitLabRequestOptions(this.integration.config), + { + ...getGitLabRequestOptions(this.integration.config), + ...(signal && { signal }), + }, ); if (!archiveGitLabResponse.ok) { const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; @@ -191,6 +200,7 @@ export class GitlabUrlReader implements UrlReader { const tree = await this.readTree(treeUrl, { etag: options?.etag, + signal: options?.signal, filter: path => matcher.match(stripFirstDirectoryFromPath(path)), }); const files = await tree.files(); diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 7e0904f18b..16edf81ba6 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -17,6 +17,7 @@ import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { AbortSignal } from 'node-abort-controller'; /** * A generic interface for fetching plain data from URLs. @@ -101,6 +102,15 @@ export type ReadUrlOptions = { * of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -165,6 +175,15 @@ export type ReadTreeOptions = { * rest of the response along with a new ETag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** @@ -291,6 +310,15 @@ export type SearchOptions = { * search will return the rest of SearchResponse along with a new etag. */ etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; }; /** diff --git a/packages/backend-tasks/.eslintrc.js b/packages/backend-tasks/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/backend-tasks/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/backend-tasks/README.md b/packages/backend-tasks/README.md new file mode 100644 index 0000000000..d3c361c02c --- /dev/null +++ b/packages/backend-tasks/README.md @@ -0,0 +1,35 @@ +# @backstage/backend-tasks + +Common distributed task management for Backstage backends. + +## Usage + +Add the library to your backend package: + +```sh +# From your Backstage root directory +cd packages/backend +yarn add @backstage/backend-tasks +``` + +then make use of its facilities as necessary: + +```typescript +import { TaskScheduler } from '@backstage/backend-tasks'; +import { Duration } from 'luxon'; + +const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin'); + +await scheduler.scheduleTask({ + id: 'refresh-things', + frequency: Duration.fromObject({ minutes: 10 }), + fn: async () => { + await entityProvider.run(); + }, +}); +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/backend-tasks/api-report.md b/packages/backend-tasks/api-report.md new file mode 100644 index 0000000000..eeb6412840 --- /dev/null +++ b/packages/backend-tasks/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/backend-tasks" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; +import { Config } from '@backstage/config'; +import { DatabaseManager } from '@backstage/backend-common'; +import { Duration } from 'luxon'; +import { Logger as Logger_2 } from 'winston'; + +// @public +export interface PluginTaskScheduler { + scheduleTask(task: TaskDefinition): Promise; +} + +// @public +export interface TaskDefinition { + fn: TaskFunction; + frequency: Duration; + id: string; + initialDelay?: Duration; + signal?: AbortSignal_2; + timeout: Duration; +} + +// @public +export type TaskFunction = + | ((abortSignal: AbortSignal_2) => void | Promise) + | (() => void | Promise); + +// @public +export class TaskScheduler { + constructor(databaseManager: DatabaseManager, logger: Logger_2); + forPlugin(pluginId: string): PluginTaskScheduler; + // (undocumented) + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger_2; + }, + ): TaskScheduler; +} +``` diff --git a/packages/backend-tasks/migrations/20210928160613_init.js b/packages/backend-tasks/migrations/20210928160613_init.js new file mode 100644 index 0000000000..21d80ae24c --- /dev/null +++ b/packages/backend-tasks/migrations/20210928160613_init.js @@ -0,0 +1,64 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // + // tasks + // + await knex.schema.createTable('backstage_backend_tasks__tasks', table => { + table.comment('Tasks used for scheduling work on multiple workers'); + table + .text('id') + .primary() + .notNullable() + .comment('The unique ID of this particular task'); + table + .text('settings_json') + .notNullable() + .comment('JSON serialized object with properties for this task'); + table + .dateTime('next_run_start_at') + .notNullable() + .comment('The next time that the task should be started'); + table + .text('current_run_ticket') + .nullable() + .comment('A unique ticket for the current task run'); + table + .dateTime('current_run_started_at') + .nullable() + .comment('The time that the current task run started'); + table + .dateTime('current_run_expires_at') + .nullable() + .comment('The time that the current task run will time out'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // + // tasks + // + await knex.schema.dropTable('backstage_backend_tasks__tasks'); +}; diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json new file mode 100644 index 0000000000..10db5cfaac --- /dev/null +++ b/packages/backend-tasks/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/backend-tasks", + "description": "Common distributed task management library for Backstage backends", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-tasks" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.8", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.4", + "@backstage/types": "^0.1.1", + "@types/luxon": "^2.0.4", + "knex": "^0.95.1", + "lodash": "^4.17.21", + "luxon": "^2.0.2", + "node-abort-controller": "^3.0.1", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "zod": "^3.9.5" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^0.1.8", + "@backstage/cli": "^0.8.1", + "jest": "^26.0.1", + "wait-for-expect": "^3.0.2" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/packages/backend-tasks/src/database/migrateBackendTasks.ts b/packages/backend-tasks/src/database/migrateBackendTasks.ts new file mode 100644 index 0000000000..cc0f4349ac --- /dev/null +++ b/packages/backend-tasks/src/database/migrateBackendTasks.ts @@ -0,0 +1,31 @@ +/* + * 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 { resolvePackagePath } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DB_MIGRATIONS_TABLE } from './tables'; + +const migrationsDir = resolvePackagePath( + '@backstage/backend-tasks', + 'migrations', +); + +export async function migrateBackendTasks(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + tableName: DB_MIGRATIONS_TABLE, + }); +} diff --git a/packages/backend-tasks/src/database/tables.ts b/packages/backend-tasks/src/database/tables.ts new file mode 100644 index 0000000000..63aad6e42a --- /dev/null +++ b/packages/backend-tasks/src/database/tables.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export const DB_MIGRATIONS_TABLE = 'backstage_backend_tasks__knex_migrations'; +export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks'; + +export type DbTasksRow = { + id: string; + settings_json: string; + next_run_start_at: Date; + current_run_ticket?: string; + current_run_started_at?: Date | string; + current_run_expires_at?: Date | string; +}; diff --git a/packages/backend-tasks/src/index.ts b/packages/backend-tasks/src/index.ts new file mode 100644 index 0000000000..dd75aca68c --- /dev/null +++ b/packages/backend-tasks/src/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Common distributed task management library for Backstage backends + * + * @packageDocumentation + */ + +export * from './tasks'; diff --git a/packages/backend-tasks/src/setupTests.ts b/packages/backend-tasks/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/packages/backend-tasks/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 {}; diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts new file mode 100644 index 0000000000..e387b85413 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; + +describe('PluginTaskManagerImpl', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function init(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + const manager = new PluginTaskSchedulerImpl( + async () => knex, + getVoidLogger(), + ); + return { knex, manager }; + } + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('scheduleTask', () => { + it.each(databases.eachSupportedId())( + 'can run the happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); + }); +}); diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts new file mode 100644 index 0000000000..93975bc327 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts @@ -0,0 +1,50 @@ +/* + * 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 { Knex } from 'knex'; +import { Logger } from 'winston'; +import { TaskWorker } from './TaskWorker'; +import { PluginTaskScheduler, TaskDefinition } from './types'; +import { validateId } from './util'; + +/** + * Implements the actual task management. + */ +export class PluginTaskSchedulerImpl implements PluginTaskScheduler { + constructor( + private readonly databaseFactory: () => Promise, + private readonly logger: Logger, + ) {} + + async scheduleTask(task: TaskDefinition): Promise { + validateId(task.id); + + const knex = await this.databaseFactory(); + + const worker = new TaskWorker(task.id, task.fn, knex, this.logger); + await worker.start( + { + version: 1, + initialDelayDuration: task.initialDelay?.toISO(), + recurringAtMostEveryDuration: task.frequency.toISO(), + timeoutAfterDuration: task.timeout.toISO(), + }, + { + signal: task.signal, + }, + ); + } +} diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts new file mode 100644 index 0000000000..8b90afff42 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts @@ -0,0 +1,83 @@ +/* + * 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 { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { sleep } from './util'; + +/** + * Makes sure to auto-expire and clean up things that time out or for other + * reasons should not be left lingering. + */ +export class PluginTaskSchedulerJanitor { + private readonly knex: Knex; + private readonly waitBetweenRuns: Duration; + private readonly logger: Logger; + + constructor(options: { + knex: Knex; + waitBetweenRuns: Duration; + logger: Logger; + }) { + this.knex = options.knex; + this.waitBetweenRuns = options.waitBetweenRuns; + this.logger = options.logger; + } + + async start(abortSignal?: AbortSignal) { + while (!abortSignal?.aborted) { + try { + await this.runOnce(); + } catch (e) { + this.logger.warn(`Error while performing janitorial tasks, ${e}`); + } + + await sleep(this.waitBetweenRuns, abortSignal); + } + } + + private async runOnce() { + // SQLite currently (Oct 1 2021) returns a number for returning() + // statements, effectively ignoring them and instead returning the outcome + // of the delete() - and knex also emits a warning about that fact, which + // is why we avoid that entirely for the sqlite3 driver. + // https://github.com/knex/knex/issues/4370 + // https://github.com/mapbox/node-sqlite3/issues/1453 + + const dbNull = this.knex.raw('null'); + + const tasksQuery = this.knex(DB_TASKS_TABLE) + .where('current_run_expires_at', '<', this.knex.fn.now()) + .update({ + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + if (this.knex.client.config.client === 'sqlite3') { + const tasks = await tasksQuery; + this.logger.warn(`${tasks} tasks timed out and were lost`); + } else { + const tasks = await tasksQuery.returning(['id']); + for (const { id } of tasks) { + this.logger.warn(`Task timed out and was lost: ${id}`); + } + } + } +} diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.test.ts b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts new file mode 100644 index 0000000000..ce8e797503 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import { TaskScheduler } from './TaskScheduler'; +import waitForExpect from 'wait-for-expect'; + +describe('TaskScheduler', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function createDatabase( + databaseId: TestDatabaseId, + ): Promise { + const knex = await databases.init(databaseId); + const databaseManager: Partial = { + forPlugin: () => ({ + getClient: async () => knex, + }), + }; + return databaseManager as DatabaseManager; + } + + it.each(databases.eachSupportedId())( + 'can return a working plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.ts b/packages/backend-tasks/src/tasks/TaskScheduler.ts new file mode 100644 index 0000000000..f1668a6691 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.ts @@ -0,0 +1,80 @@ +/* + * 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 { DatabaseManager, getRootLogger } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { once } from 'lodash'; +import { Duration } from 'luxon'; +import { Logger } from 'winston'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; +import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor'; +import { PluginTaskScheduler } from './types'; + +/** + * Deals with the scheduling of distributed tasks. + * + * @public + */ +export class TaskScheduler { + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger; + }, + ): TaskScheduler { + const databaseManager = + options?.databaseManager ?? DatabaseManager.fromConfig(config); + const logger = (options?.logger || getRootLogger()).child({ + type: 'taskManager', + }); + return new TaskScheduler(databaseManager, logger); + } + + constructor( + private readonly databaseManager: DatabaseManager, + private readonly logger: Logger, + ) {} + + /** + * Instantiates a task manager instance for the given plugin. + * + * @param pluginId - The unique ID of the plugin, for example "catalog" + * @returns A {@link PluginTaskScheduler} instance + */ + forPlugin(pluginId: string): PluginTaskScheduler { + const databaseFactory = once(async () => { + const knex = await this.databaseManager.forPlugin(pluginId).getClient(); + + await migrateBackendTasks(knex); + + const janitor = new PluginTaskSchedulerJanitor({ + knex, + waitBetweenRuns: Duration.fromObject({ minutes: 1 }), + logger: this.logger, + }); + janitor.start(); + + return knex; + }); + + return new PluginTaskSchedulerImpl( + databaseFactory, + this.logger.child({ plugin: pluginId }), + ); + } +} diff --git a/packages/backend-tasks/src/tasks/TaskWorker.test.ts b/packages/backend-tasks/src/tasks/TaskWorker.test.ts new file mode 100644 index 0000000000..2f977ca639 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.test.ts @@ -0,0 +1,245 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskWorker } from './TaskWorker'; +import { TaskSettingsV1 } from './types'; + +describe('TaskWorker', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each(databases.eachSupportedId())( + 'goes through the expected states, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: Duration.fromMillis(1000).toISO(), + recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + expect(JSON.parse(row.settings_json)).toEqual({ + version: 1, + initialDelayDuration: 'PT1S', + recurringAtMostEveryDuration: 'PT2S', + timeoutAfterDuration: 'PT60S', + }); + + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'not-ready-yet', + }); + + waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + }); + }); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + true, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'runs tasks more than once even when the task throws, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn().mockRejectedValue(new Error('failed')); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: undefined, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + worker.start(settings); + + waitForExpect(() => { + expect(fn).toBeCalledTimes(3); + }); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'does not clobber ticket lock when stolen, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await knex(DB_TASKS_TABLE) + .where('id', '=', 'task1') + .update({ current_run_ticket: 'stolen' }); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'stolen', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'gracefully handles a disappeared task row, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn(async () => {}); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker1 = new TaskWorker('task1', fn, knex, logger); + await worker1.persistTask(settings); + await knex(DB_TASKS_TABLE).where('id', '=', 'task1').delete(); + await expect(worker1.findReadyTask()).resolves.toEqual({ + result: 'abort', + }); + + const worker2 = new TaskWorker('task2', fn, knex, logger); + await worker2.persistTask(settings); + await expect(worker2.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await knex(DB_TASKS_TABLE).where('id', '=', 'task2').delete(); + await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe( + false, + ); + + const worker3 = new TaskWorker('task3', fn, knex, logger); + await worker3.persistTask(settings); + await expect(worker3.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe( + true, + ); + await knex(DB_TASKS_TABLE).where('id', '=', 'task3').delete(); + await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.ts b/packages/backend-tasks/src/tasks/TaskWorker.ts new file mode 100644 index 0000000000..991d97b956 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.ts @@ -0,0 +1,249 @@ +/* + * 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 { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { v4 as uuid } from 'uuid'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types'; +import { delegateAbortController, nowPlus, sleep } from './util'; + +const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 }); + +/** + * Performs the actual work of a task. + * + * @private + */ +export class TaskWorker { + private readonly taskId: string; + private readonly fn: TaskFunction; + private readonly knex: Knex; + private readonly logger: Logger; + + constructor(taskId: string, fn: TaskFunction, knex: Knex, logger: Logger) { + this.taskId = taskId; + this.fn = fn; + this.knex = knex; + this.logger = logger; + } + + async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) { + try { + await this.persistTask(settings); + } catch (e) { + throw new Error(`Failed to persist task, ${e}`); + } + + this.logger.info( + `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, + ); + + (async () => { + try { + while (!options?.signal?.aborted) { + const runResult = await this.runOnce(options?.signal); + if (runResult.result === 'abort') { + break; + } + + await sleep(WORK_CHECK_FREQUENCY, options?.signal); + } + this.logger.info(`Task worker finished: ${this.taskId}`); + } catch (e) { + this.logger.warn(`Task worker failed unexpectedly, ${e}`); + } + })(); + } + + /** + * Makes a single attempt at running the task to completion, if ready. + * + * @returns The outcome of the attempt + */ + async runOnce( + signal?: AbortSignal, + ): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'failed' } + | { result: 'completed' } + > { + const findResult = await this.findReadyTask(); + if ( + findResult.result === 'not-ready-yet' || + findResult.result === 'abort' + ) { + return findResult; + } + + const taskSettings = findResult.settings; + const ticket = uuid(); + + const claimed = await this.tryClaimTask(ticket, taskSettings); + if (!claimed) { + return { result: 'not-ready-yet' }; + } + + // Abort the task execution either if the worker is stopped, or if the + // task timeout is hit + const taskAbortController = delegateAbortController(signal); + const timeoutHandle = setTimeout(() => { + taskAbortController.abort(); + }, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds')); + + try { + await this.fn(taskAbortController.signal); + } catch (e) { + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'failed' }; + } finally { + clearTimeout(timeoutHandle); + } + + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'completed' }; + } + + /** + * Perform the initial store of the task info + */ + async persistTask(settings: TaskSettingsV1) { + // Perform an initial parse to ensure that we will definitely be able to + // read it back again. + taskSettingsV1Schema.parse(settings); + + const settingsJson = JSON.stringify(settings); + const startAt = settings.initialDelayDuration + ? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex) + : this.knex.fn.now(); + + // It's OK if the task already exists; if it does, just replace its + // settings with the new value and start the loop as usual. + await this.knex(DB_TASKS_TABLE) + .insert({ + id: this.taskId, + settings_json: settingsJson, + next_run_start_at: startAt, + }) + .onConflict('id') + .merge(['settings_json']); + } + + /** + * Check if the task is ready to run + */ + async findReadyTask(): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'ready'; settings: TaskSettingsV1 } + > { + const [row] = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .select({ + settingsJson: 'settings_json', + ready: this.knex.raw( + ` + CASE + WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE + ELSE FALSE + END`, + [this.knex.fn.now()], + ), + }); + + if (!row) { + this.logger.info( + 'No longer able to find task; aborting and assuming that it has been unregistered or expired', + ); + return { result: 'abort' }; + } else if (!row.ready) { + return { result: 'not-ready-yet' }; + } + + try { + const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson)); + return { result: 'ready', settings }; + } catch (e) { + this.logger.info( + `Task "${this.taskId}" is no longer able to parse task settings; aborting and assuming that a ` + + `newer version of the task has been issued and being handled by other workers, ${e}`, + ); + return { result: 'abort' }; + } + } + + /** + * Attempts to claim a task that's ready for execution, on this worker's + * behalf. We should not attempt to perform the work unless the claim really + * goes through. + * + * @param ticket - A globally unique string that changes for each invocation + * @param settings - The settings of the task to claim + * @returns True if it was successfully claimed + */ + async tryClaimTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const startedAt = this.knex.fn.now(); + const expiresAt = settings.timeoutAfterDuration + ? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex) + : this.knex.raw('null'); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .whereNull('current_run_ticket') + .update({ + current_run_ticket: ticket, + current_run_started_at: startedAt, + current_run_expires_at: expiresAt, + }); + + return rows === 1; + } + + async tryReleaseTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const { recurringAtMostEveryDuration } = settings; + + // We make an effort to keep the datetime calculations in the database + // layer, making sure to not have to perform conversions back and forth and + // leaning on the database as a central clock source + const dbNull = this.knex.raw('null'); + const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds'); + const nextRun = + this.knex.client.config.client === 'sqlite3' + ? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`]) + : this.knex.raw(`next_run_start_at + interval '${dt} seconds'`); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .where('current_run_ticket', '=', ticket) + .update({ + next_run_start_at: nextRun, + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + return rows === 1; + } +} diff --git a/packages/backend-tasks/src/tasks/index.ts b/packages/backend-tasks/src/tasks/index.ts new file mode 100644 index 0000000000..9e0a06f71c --- /dev/null +++ b/packages/backend-tasks/src/tasks/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { TaskScheduler } from './TaskScheduler'; +export type { + PluginTaskScheduler, + TaskDefinition, + TaskFunction, +} from './types'; diff --git a/packages/backend-tasks/src/tasks/types.ts b/packages/backend-tasks/src/tasks/types.ts new file mode 100644 index 0000000000..4693af7ef3 --- /dev/null +++ b/packages/backend-tasks/src/tasks/types.ts @@ -0,0 +1,138 @@ +/* + * 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 { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { z } from 'zod'; + +/** + * A function that can be called as a scheduled task. + * + * It may optionally accept an abort signal argument. When the signal triggers, + * processing should abort and return as quickly as possible. + * + * @public + */ +export type TaskFunction = + | ((abortSignal: AbortSignal) => void | Promise) + | (() => void | Promise); + +/** + * Options that apply to the invocation of a given task. + * + * @public + */ +export interface TaskDefinition { + /** + * A unique ID (within the scope of the plugin) for the task. + */ + id: string; + + /** + * The actual task function to be invoked regularly. + */ + fn: TaskFunction; + + /** + * An abort signal that, when triggered, will stop the recurring execution of + * the task. + */ + signal?: AbortSignal; + + /** + * The maximum amount of time that a single task invocation can take, before + * it's considered timed out and gets "released" such that a new invocation + * is permitted to take place (possibly, then, on a different worker). + * + * If no value is given for this field then there is no timeout. This is + * potentially dangerous. + */ + timeout: Duration; + + /** + * The amount of time that should pass between task invocation starts. + * Essentially, this equals roughly how often you want the task to run. + * + * This is a best effort value; under some circumstances there can be + * deviations. For example, if the task runtime is longer than the frequency + * and the timeout has not been given or not been exceeded yet, the next + * invocation of this task will be delayed until after the previous one + * finishes. + * + * The system does its best to avoid overlapping invocations. + * + * If no value is given for this field then the task will only be invoked + * once (on any worker) and then unscheduled automatically. + */ + frequency: Duration; + + /** + * The amount of time that should pass before the first invocation happens. + * + * This can be useful in cold start scenarios to stagger or delay some heavy + * compute jobs. + * + * If no value is given for this field then the first invocation will happen + * as soon as possible. + */ + initialDelay?: Duration; +} + +/** + * Deals with the scheduling of distributed tasks, for a given plugin. + * + * @public + */ +export interface PluginTaskScheduler { + /** + * Schedules a task function for coordinated exclusive invocation across + * workers. + * + * If the task was already scheduled since before by us or by another party, + * its options are just overwritten with the given options, and things + * continue from there. + * + * @param definition - The task definition + */ + scheduleTask(task: TaskDefinition): Promise; +} + +function isValidOptionalDurationString(d: string | undefined): boolean { + try { + return !d || Duration.fromISO(d).isValid === true; + } catch { + return false; + } +} + +export const taskSettingsV1Schema = z.object({ + version: z.literal(1), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + recurringAtMostEveryDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + timeoutAfterDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), +}); + +/** + * The properties that control a scheduled task (version 1). + */ +export type TaskSettingsV1 = z.infer; diff --git a/packages/backend-tasks/src/tasks/util.test.ts b/packages/backend-tasks/src/tasks/util.test.ts new file mode 100644 index 0000000000..f74669a7f4 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { Duration } from 'luxon'; +import { AbortController } from 'node-abort-controller'; +import { delegateAbortController, sleep, validateId } from './util'; + +describe('util', () => { + describe('validateId', () => { + it.each(['a', 'a_b', 'ab123c_2'])( + 'accepts valid inputs, %p', + async input => { + expect(validateId(input)).toBeUndefined(); + }, + ); + + it.each(['', 'a!', 'A', 'a-b', 'a.b', '_a', 'a_', null, Symbol('a')])( + 'rejects invalid inputs, %p', + async input => { + expect(() => validateId(input as any)).toThrow(); + }, + ); + }); + + describe('sleep', () => { + it('finishes the wait as expected with no signal', async () => { + const ac = new AbortController(); + const start = Date.now(); + await sleep(Duration.fromObject({ seconds: 1 }), ac.signal); + expect(Date.now() - start).toBeGreaterThan(800); + }, 5_000); + + it('aborts properly on the signal', async () => { + const ac = new AbortController(); + const promise = sleep(Duration.fromObject({ seconds: 10 }), ac.signal); + ac.abort(); + await promise; + expect(true).toBe(true); + }, 1_000); + }); + + describe('delegateAbortController', () => { + it('inherits parent abort state', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + parent.abort(); + expect(parent.signal.aborted).toBe(true); + expect(child.signal.aborted).toBe(true); + }); + + it('does not inherit from child to parent', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + child.abort(); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(true); + }); + }); +}); diff --git a/packages/backend-tasks/src/tasks/util.ts b/packages/backend-tasks/src/tasks/util.ts new file mode 100644 index 0000000000..0509f29363 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.ts @@ -0,0 +1,107 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { Knex } from 'knex'; +import { DateTime, Duration } from 'luxon'; +import { AbortController, AbortSignal } from 'node-abort-controller'; + +// Keep the IDs compatible with e.g. Prometheus +export function validateId(id: string) { + if (typeof id !== 'string' || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(id)) { + throw new InputError( + `${id} is not a valid ID, expected string of lowercase characters and digits separated by underscores`, + ); + } +} + +export function dbTime(t: Date | string): DateTime { + if (typeof t === 'string') { + return DateTime.fromSQL(t); + } + return DateTime.fromJSDate(t); +} + +export function nowPlus(duration: Duration | undefined, knex: Knex) { + const seconds = duration?.as('seconds') ?? 0; + if (!seconds) { + return knex.fn.now(); + } + return knex.client.config.client === 'sqlite3' + ? knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]) + : knex.raw(`now() + interval '${seconds} seconds'`); +} + +/** + * Sleep for the given duration, but return sooner if the abort signal + * triggers. + * + * @param duration - The amount of time to sleep, at most + * @param abortSignal - An optional abort signal that short circuits the wait + */ +export async function sleep( + duration: Duration, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + return; + } + + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + + const done = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + abortSignal?.removeEventListener('abort', done); + resolve(); + }; + + timeoutHandle = setTimeout(done, duration.as('milliseconds')); + abortSignal?.addEventListener('abort', done); + }); +} + +/** + * Creates a new AbortController that, in addition to working as a regular + * standalone controller, also gets aborted if the given parent signal + * reaches aborted state. + * + * @param parent - The "parent" signal that can trigger the delegate + */ +export function delegateAbortController(parent?: AbortSignal): AbortController { + const delegate = new AbortController(); + + if (parent) { + if (parent.aborted) { + delegate.abort(); + } else { + const onParentAborted = () => { + delegate.abort(); + }; + + const onChildAborted = () => { + parent.removeEventListener('abort', onParentAborted); + }; + + parent.addEventListener('abort', onParentAborted, { once: true }); + delegate.signal.addEventListener('abort', onChildAborted, { once: true }); + } + } + + return delegate; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ffdce949b7..f978e84da9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -48,12 +48,12 @@ import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; import search from './plugins/search'; import techdocs from './plugins/techdocs'; +import techInsights from './plugins/techInsights'; import todo from './plugins/todo'; import graphql from './plugins/graphql'; import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; -import techInsights from './plugins/techInsights'; import { PluginEnvironment } from './types'; function makeCreateEnv(config: Config) { diff --git a/packages/backend/src/plugins/techInsights.ts b/packages/backend/src/plugins/techInsights.ts index abab1d133d..8ab6342a4f 100644 --- a/packages/backend/src/plugins/techInsights.ts +++ b/packages/backend/src/plugins/techInsights.ts @@ -17,10 +17,12 @@ import { createRouter, buildTechInsightsContext, createFactRetrieverRegistration, + entityOwnershipFactRetriever, + entityMetadataFactRetriever, + techdocsFactRetriever, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { CatalogClient } from '@backstage/catalog-client'; import { JsonRulesEngineFactCheckerFactory, JSON_RULE_ENGINE_CHECK_TYPE, @@ -38,41 +40,12 @@ export default async function createPlugin({ database, discovery, factRetrievers: [ - createFactRetrieverRegistration('5 4 * * 6', { - // Example cron, At 04:05 on Saturday. - id: 'testRetriever', - version: '1.1.2', - entityFilter: [{ kind: 'component' }], // EntityFilter to be used in the future (creating checks, graphs etc.) to figure out which entities this fact retrieves data for. - schema: { - examplenumberfact: { - type: 'integer', - description: 'Example fact returning a number', - }, - }, - handler: async _ctx => { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - const entities = await catalogClient.getEntities({ - filter: [{ kind: 'component' }], - }); - - return Promise.resolve( - entities.items.map(it => { - return { - entity: { - namespace: it.metadata.namespace!!, - kind: it.kind, - name: it.metadata.name, - }, - facts: { - examplenumberfact: 2, - }, - }; - }), - ); - }, - }), + createFactRetrieverRegistration( + '* * * * *', // Example cron, every minute + entityOwnershipFactRetriever, + ), + createFactRetrieverRegistration('* * * * *', entityMetadataFactRetriever), + createFactRetrieverRegistration('* * * * *', techdocsFactRetriever), ], factCheckerFactory: new JsonRulesEngineFactCheckerFactory({ checks: [ @@ -81,14 +54,33 @@ export default async function createPlugin({ type: JSON_RULE_ENGINE_CHECK_TYPE, name: 'simpleTestCheck', description: 'Simple Check For Testing', - factIds: ['testRetriever'], + factIds: [ + 'entityMetadataFactRetriever', + 'techdocsFactRetriever', + 'entityOwnershipFactRetriever', + ], rule: { conditions: { all: [ { - fact: 'examplenumberfact', - operator: 'lessThan', - value: 5, + fact: 'hasGroupOwner', + operator: 'equal', + value: true, + }, + { + fact: 'hasTitle', + operator: 'equal', + value: true, + }, + { + fact: 'hasDescription', + operator: 'equal', + value: true, + }, + { + fact: 'hasAnnotationBackstageIoTechdocsRef', + operator: 'equal', + value: true, }, ], }, diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index efdaacc1e0..9caac50c90 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.5", - "@backstage/errors": "^0.1.3", + "@backstage/errors": "^0.1.4", "cross-fetch": "^3.0.6" }, "devDependencies": { diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index b64e536578..17c89ed824 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -36,7 +36,7 @@ "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.13", "ajv": "^7.0.3", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "uuid": "^8.0.0", "yup": "^0.32.9" diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index 4b61cfedb8..34f56c3f19 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -3,6 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +// @public +export const BACKSTAGE_JSON = 'backstage.json'; + // @public export function findPaths(searchDir: string): Paths; diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index e7cda19514..8809764b43 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -20,6 +20,6 @@ * @packageDocumentation */ -export { findPaths } from './paths'; +export { findPaths, BACKSTAGE_JSON } from './paths'; export { isChildPath } from './isChildPath'; export type { Paths, ResolveFunc } from './paths'; diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index c9f486752f..2b1ca34ca5 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -167,3 +167,10 @@ export function findPaths(searchDir: string): Paths { resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths), }; } + +/** + * The name of the backstage's config file + * + * @public + */ +export const BACKSTAGE_JSON = 'backstage.json'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 546a1597e1..fcbc00f47d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", "@octokit/request": "^5.4.12", - "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-yaml": "^3.0.0", @@ -81,7 +81,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.2", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index a5ea565cae..c80129662c 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -263,7 +263,6 @@ export default async (cmd: Command) => { const pluginDir = isMonoRepo ? paths.resolveTargetRoot('plugins', pluginId) : paths.resolveTargetRoot(pluginId); - const ownerIds = parseOwnerIds(answers.owner); const { version: pluginVersion } = isMonoRepo ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) : { version: '0.1.0' }; @@ -318,12 +317,8 @@ export default async (cmd: Command) => { await addPluginExtensionToApp(pluginId, extensionName, name); } - if (ownerIds && ownerIds.length) { - await addCodeownersEntry( - codeownersPath!, - `/plugins/${pluginId}`, - ownerIds, - ); + if (answers.owner) { + await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner); } Task.log(); diff --git a/packages/cli/src/commands/create/create.ts b/packages/cli/src/commands/create/create.ts new file mode 100644 index 0000000000..64067e1b7e --- /dev/null +++ b/packages/cli/src/commands/create/create.ts @@ -0,0 +1,135 @@ +/* + * 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 { join as joinPath } from 'path'; +import { Command } from 'commander'; +import { FactoryRegistry } from '../../lib/create/FactoryRegistry'; +import { paths } from '../../lib/paths'; +import { assertError } from '@backstage/errors'; +import { Task } from '../../lib/tasks'; + +function parseOptions(optionStrings: string[]): Record { + const options: Record = {}; + + for (const str of optionStrings) { + const [key] = str.split('=', 1); + const value = str.slice(key.length + 1); + if (!key || str[key.length] !== '=') { + throw new Error( + `Invalid option '${str}', must be of the format =`, + ); + } + options[key] = value; + } + + return options; +} + +export default async (cmd: Command) => { + const cmdOpts = cmd.opts(); + + const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select); + + const providedOptions = parseOptions(cmdOpts.option); + const options = await FactoryRegistry.populateOptions( + factory, + providedOptions, + ); + + let isMonoRepo = false; + try { + const rootPackageJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + if (rootPackageJson.workspaces) { + isMonoRepo = true; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + let defaultVersion = '0.1.0'; + try { + const rootLernaJson = await fs.readJson( + paths.resolveTargetRoot('lerna.json'), + ); + if (rootLernaJson.version) { + defaultVersion = rootLernaJson.version; + } + } catch (error) { + assertError(error); + if (error.code !== 'ENOENT') { + throw error; + } + } + + const tempDirs = new Array(); + async function createTemporaryDirectory(name: string): Promise { + const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name)); + tempDirs.push(dir); + return dir; + } + + let modified = false; + try { + await factory.create(options, { + isMonoRepo, + defaultVersion, + scope: cmdOpts.scope?.replace(/^@/, ''), + npmRegistry: cmdOpts.npmRegistry, + private: Boolean(cmdOpts.private), + createTemporaryDirectory, + markAsModified() { + modified = true; + }, + }); + + Task.log(); + Task.log(`🎉 Successfully created ${factory.name}`); + Task.log(); + } catch (error) { + assertError(error); + Task.error(error.message); + + if (modified) { + Task.log('It seems that something went wrong in the creation process 🤔'); + Task.log(); + Task.log( + 'We have left the changes that were made intact in case you want to', + ); + Task.log( + 'continue manually, but you can also revert the changes and try again.', + ); + + Task.error(`🔥 Failed to create ${factory.name}!`); + } + } finally { + for (const dir of tempDirs) { + try { + await fs.remove(dir); + } catch (error) { + console.error( + `Failed to remove temporary directory '${dir}', ${error}`, + ); + } + } + } +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4e01a8ca9f..a37d5e9ffe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); + program + .command('create') + .storeOptionsAsProperties(false) + .description( + 'Open up an interactive guide to creating new things in your app', + ) + .option( + '--select ', + 'Select the thing you want to be creating upfront', + ) + .option( + '--option =', + 'Pre-fill options for the creation process', + (opt, arr: string[]) => [...arr, opt], + [], + ) + .option('--scope ', 'The scope to use for new packages') + .option( + '--npm-registry ', + 'The package registry to use for new packages', + ) + .option('--no-private', 'Do not mark new packages as private') + .action(lazy(() => import('./create/create').then(m => m.default))); + program .command('create-plugin') .option( diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 0390320128..8859b5d4cd 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -177,9 +177,9 @@ describe('removePlugin', () => { fse.readFileSync(mockedCodeownersPath, 'utf8'), ); await addCodeownersEntry( - testFilePath!, path.join('plugins', testPluginName), - ['@thisIsAtestTeam', 'test@gmail.com'], + '@thisIsAtestTeam test@gmail.com', + testFilePath, ); await removePluginFromCodeOwners(testFilePath, testPluginName); expect(testFileContent).toBe(codeOwnersFileContent); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 96bd1ab6fd..bc4049f1f4 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path'; import { paths } from '../../lib/paths'; import { mapDependencies } from '../../lib/versioning'; import * as runObj from '../../lib/run'; -import bump from './bump'; +import bump, { bumpBackstageJsonVersion } from './bump'; import { withLogCollector } from '@backstage/test-utils'; // Remove log coloring to simplify log matching @@ -141,7 +141,7 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(3); + expect(runObj.runPlain).toHaveBeenCalledTimes(4); expect(runObj.runPlain).toHaveBeenCalledWith( 'yarn', 'info', @@ -245,3 +245,64 @@ describe('bump', () => { }); }); }); + +describe('bumpBackstageJsonVersion', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should bump version in backstage.json', async () => { + mockFs({ + '/backstage.json': JSON.stringify({ version: '0.0.1' }), + }); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); + + it("should create backstage.json if doesn't exist", async () => { + mockFs({}); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); +}); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 7b967839b2..3898c1654a 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -27,6 +27,7 @@ import { Lockfile, } from '../../lib/versioning'; import { includedFilter, forbiddenDuplicatesFilter } from './lint'; +import { BACKSTAGE_JSON } from '@backstage/cli-common'; const DEP_TYPES = [ 'dependencies', @@ -182,6 +183,10 @@ export default async () => { await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); }); + console.log(); + + await bumpBackstageJsonVersion(); + console.log(); console.log( `Running ${chalk.blue('yarn install')} to install new versions`, @@ -271,6 +276,41 @@ function createVersionFinder() { }; } +export async function bumpBackstageJsonVersion() { + const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON); + const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => { + if (e.code === 'ENOENT') { + // gracefully continue in case the file doesn't exist + return; + } + throw e; + }); + + const info = await fetchPackageInfo('@backstage/create-app'); + const { latest } = info['dist-tags']; + + if (backstageJson?.version === latest) { + return; + } + + console.log( + chalk.yellow( + typeof backstageJson === 'undefined' + ? `Creating ${BACKSTAGE_JSON}` + : `Bumping version in ${BACKSTAGE_JSON}`, + ), + ); + + await fs.writeJson( + backstageJsonPath, + { ...backstageJson, version: latest }, + { + spaces: 2, + encoding: 'utf8', + }, + ); +} + async function workerThreads( count: number, items: IterableIterator, diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 563bd1052d..5734be95b8 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import path from 'path'; +import { paths } from '../paths'; const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; const USER_ID_RE = /^@[-\w]+$/; @@ -30,14 +31,14 @@ type CodeownersEntry = { export async function getCodeownersFilePath( rootDir: string, ): Promise { - const paths = [ + const possiblePaths = [ path.join(rootDir, '.github', 'CODEOWNERS'), path.join(rootDir, '.gitlab', 'CODEOWNERS'), path.join(rootDir, 'docs', 'CODEOWNERS'), path.join(rootDir, 'CODEOWNERS'), ]; - for (const p of paths) { + for (const p of possiblePaths) { if (await fs.pathExists(p)) { return p; } @@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean { } export function parseOwnerIds( - spaceSeparatedOwnerIds: string, + spaceSeparatedOwnerIds: string | undefined, ): string[] | undefined { if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') { return undefined; @@ -70,11 +71,24 @@ export function parseOwnerIds( } export async function addCodeownersEntry( - codeownersFilePath: string, ownedPath: string, - ownerIds: string[], -): Promise { - const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n'); + ownerStr: string, + codeownersFilePath?: string, +): Promise { + const ownerIds = parseOwnerIds(ownerStr); + if (!ownerIds || ownerIds.length === 0) { + return false; + } + + let filePath = codeownersFilePath; + if (!filePath) { + filePath = await getCodeownersFilePath(paths.targetRoot); + if (!filePath) { + return false; + } + } + + const allLines = (await fs.readFile(filePath, 'utf8')).split('\n'); // Only keep comments from the top of the file const commentLines = []; @@ -117,5 +131,7 @@ export async function addCodeownersEntry( const newLines = [...commentLines, '', ...newDeclarationLines, '']; - await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8'); + await fs.writeFile(filePath, newLines.join('\n'), 'utf8'); + + return true; } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 6f93318ead..d343a554f8 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -54,7 +54,7 @@ export async function loadCliConfig(options: Options) { packagePaths: [paths.resolveTargetRoot('package.json')], }); - const appConfigs = await loadConfig({ + const { appConfigs } = await loadConfig({ experimentalEnvFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, diff --git a/packages/cli/src/lib/create/FactoryRegistry.ts b/packages/cli/src/lib/create/FactoryRegistry.ts new file mode 100644 index 0000000000..9072d46c4c --- /dev/null +++ b/packages/cli/src/lib/create/FactoryRegistry.ts @@ -0,0 +1,126 @@ +/* + * 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 chalk from 'chalk'; +import inquirer from 'inquirer'; +import { AnyFactory, Prompt } from './types'; +import * as factories from './factories'; +import partition from 'lodash/partition'; + +function applyPromptMessageTransforms( + prompt: Prompt, + transforms: { + message: (msg: string) => string; + error: (msg: string) => string; + }, +): Prompt { + return { + ...prompt, + message: + prompt.message && + (async answers => { + if (typeof prompt.message === 'function') { + return transforms.message(await prompt.message(answers)); + } + return transforms.message(await prompt.message!); + }), + validate: + prompt.validate && + (async (...args) => { + const result = await prompt.validate!(...args); + if (typeof result === 'string') { + return transforms.error(result); + } + return result; + }), + }; +} + +export class FactoryRegistry { + private static factoryMap = new Map( + Object.values(factories).map(factory => [factory.name, factory]), + ); + + static async interactiveSelect(preselected?: string): Promise { + let selected = preselected; + + if (!selected) { + const answers = await inquirer.prompt<{ name: string }>([ + { + type: 'list', + name: 'name', + message: 'What do you want to create?', + choices: Array.from(this.factoryMap.values()).map(factory => ({ + name: `${factory.name} - ${factory.description}`, + value: factory.name, + })), + }, + ]); + selected = answers.name; + } + + const factory = this.factoryMap.get(selected); + if (!factory) { + throw new Error(`Unknown selection '${selected}'`); + } + return factory; + } + + static async populateOptions( + factory: AnyFactory, + provided: Record, + ): Promise> { + let currentOptions = provided; + + if (factory.optionsDiscovery) { + const discoveredOptions = await factory.optionsDiscovery(); + currentOptions = { + ...currentOptions, + ...(discoveredOptions as Record), + }; + } + + if (factory.optionsPrompts) { + const [hasAnswers, needsAnswers] = partition( + factory.optionsPrompts, + option => option.name in currentOptions, + ); + + for (const option of hasAnswers) { + const value = provided[option.name]; + + if (option.validate) { + const result = option.validate(value); + if (result !== true) { + throw new Error(`Invalid option '${option.name}'. ${result}`); + } + } + } + + currentOptions = await inquirer.prompt( + needsAnswers.map(option => + applyPromptMessageTransforms(option, { + message: chalk.blue, + error: chalk.red, + }), + ), + currentOptions, + ); + } + + return currentOptions; + } +} diff --git a/packages/cli/src/lib/create/factories/backendPlugin.test.ts b/packages/cli/src/lib/create/factories/backendPlugin.test.ts new file mode 100644 index 0000000000..6f4a73c02d --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.test.ts @@ -0,0 +1,115 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { backendPlugin } from './backendPlugin'; + +describe('backendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a backend plugin', async () => { + mockFs({ + '/root': { + packages: { + backend: { + 'package.json': JSON.stringify({}), + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(backendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await backendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-backend', + 'Checking Prerequisites:', + `availability plugins${sep}test-backend`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'copying index.ts', + 'templating run.ts.hbs', + 'copying setupTests.ts', + 'copying router.test.ts', + 'copying router.ts', + 'templating standaloneServer.ts.hbs', + 'Installing:', + `moving plugins${sep}test-backend`, + 'backend adding dependency', + ]); + + await expect( + fs.readJson('/root/packages/backend/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test-backend': '^1.0.0', + }, + }); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-backend'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/backendPlugin.ts b/packages/cli/src/lib/create/factories/backendPlugin.ts new file mode 100644 index 0000000000..4d03a4da7b --- /dev/null +++ b/packages/cli/src/lib/create/factories/backendPlugin.ts @@ -0,0 +1,89 @@ +/* + * 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 fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const backendPlugin = createFactory({ + name: 'backend-plugin', + description: 'A new backend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const id = `${options.id}-backend`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-backend-plugin', + values: { + id, + name, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) { + await Task.forItem('backend', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/backend/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/common/prompts.ts b/packages/cli/src/lib/create/factories/common/prompts.ts new file mode 100644 index 0000000000..9c7672ddfb --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/prompts.ts @@ -0,0 +1,58 @@ +/* + * 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 { Prompt } from '../../types'; +import { parseOwnerIds } from '../../../codeowners'; + +export function pluginIdPrompt(): Prompt<{ id: string }> { + return { + type: 'input', + name: 'id', + message: 'Enter the ID of the plugin [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the ID of the plugin'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }; +} + +export function ownerPrompt(): Prompt<{ + owner?: string; + codeOwnersPath?: string; +}> { + return { + type: 'input', + name: 'owner', + message: 'Enter an owner to add to CODEOWNERS [optional]', + when: opts => Boolean(opts.codeOwnersPath), + validate: (value: string) => { + if (!value) { + return true; + } + + const ownerIds = parseOwnerIds(value); + if (!ownerIds) { + return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).'; + } + + return true; + }, + }; +} diff --git a/packages/cli/src/lib/create/factories/common/tasks.test.ts b/packages/cli/src/lib/create/factories/common/tasks.test.ts new file mode 100644 index 0000000000..49381676e7 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.test.ts @@ -0,0 +1,118 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep } from 'path'; +import { createMockOutputStream, mockPaths } from './testUtils'; +import { CreateContext } from '../../types'; +import { executePluginPackageTemplate } from './tasks'; + +mockPaths({ + ownDir: '/own', + targetRoot: '/root', +}); + +describe('executePluginPackageTemplate', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should execute template', async () => { + mockFs({ + '/root': { + 'yarn.lock': ` +some-package@^1.1.0: + version "1.5.0" +`, + }, + '/own': { + templates: { + 'test-template': { + 'package.json.hbs': ` +{ + "name": "my-{{id}}-plugin", + {{#if makePrivate}} + "private": true, + {{/if}} + "description": "testing", + "dependencies": { + "some-package": "{{ versionQuery 'some-package' '1.3.0' }}", + "other-package": "{{ versionQuery 'other-package' '2.3.0' }}" + } +} +`, + subdir: { + 'templated.txt.hbs': 'Hello {{id}}!', + 'not-templated.txt': 'Hello {{id}}!', + }, + }, + }, + }, + }); + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + + let modified = false; + await executePluginPackageTemplate( + { + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + markAsModified: () => { + modified = true; + }, + } as CreateContext, + { + templateName: 'test-template', + targetDir: '/target', + values: { + id: 'testing', + makePrivate: true, + }, + }, + ); + + expect(modified).toBe(true); + expect(output).toEqual([ + 'Checking Prerequisites:', + `availability ..${sep}target`, + 'creating temp dir', + 'Executing Template:', + 'templating package.json.hbs', + 'copying not-templated.txt', + 'templating templated.txt.hbs', + 'Installing:', + `moving ..${sep}target`, + ]); + await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + "name": "my-testing-plugin", + "private": true, + "description": "testing", + "dependencies": { + "some-package": "^1.1.0", + "other-package": "^2.3.0" + } +} +`); + await expect( + fs.readFile('/target/subdir/templated.txt', 'utf8'), + ).resolves.toBe('Hello testing!'); + await expect( + fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + ).resolves.toBe('Hello {{id}}!'); + }); +}); diff --git a/packages/cli/src/lib/create/factories/common/tasks.ts b/packages/cli/src/lib/create/factories/common/tasks.ts new file mode 100644 index 0000000000..93af1e2b00 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/tasks.ts @@ -0,0 +1,84 @@ +/* + * 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 fs from 'fs-extra'; +import chalk from 'chalk'; +import { resolve as resolvePath, relative as relativePath } from 'path'; +import { paths } from '../../../paths'; +import { Task, templatingTask } from '../../../tasks'; +import { Lockfile } from '../../../versioning'; +import { createPackageVersionProvider } from '../../../version'; +import { CreateContext } from '../../types'; + +export async function executePluginPackageTemplate( + ctx: CreateContext, + options: { + templateName: string; + targetDir: string; + values: Record; + }, +) { + const { targetDir } = options; + + let lockfile: Lockfile | undefined; + try { + lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + } catch { + /* ignored */ + } + + Task.section('Checking Prerequisites'); + const shortPluginDir = relativePath(paths.targetRoot, targetDir); + await Task.forItem('availability', shortPluginDir, async () => { + if (await fs.pathExists(targetDir)) { + throw new Error( + `A package with the same plugin ID already exists at ${chalk.cyan( + shortPluginDir, + )}. Please try again with a different ID.`, + ); + } + }); + + const tempDir = await Task.forItem('creating', 'temp dir', async () => { + return await ctx.createTemporaryDirectory('backstage-create'); + }); + + Task.section('Executing Template'); + await templatingTask( + paths.resolveOwn('templates', options.templateName), + tempDir, + options.values, + createPackageVersionProvider(lockfile), + ); + + // Format package.json if it exists + const pkgJsonPath = resolvePath(tempDir, 'package.json'); + if (await fs.pathExists(pkgJsonPath)) { + const pkgJson = await fs.readJson(pkgJsonPath); + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + } + + Task.section('Installing'); + await Task.forItem('moving', shortPluginDir, async () => { + await fs.move(tempDir, targetDir).catch(error => { + throw new Error( + `Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`, + ); + }); + }); + + ctx.markAsModified(); +} diff --git a/packages/cli/src/lib/create/factories/common/testUtils.ts b/packages/cli/src/lib/create/factories/common/testUtils.ts new file mode 100644 index 0000000000..01081a7486 --- /dev/null +++ b/packages/cli/src/lib/create/factories/common/testUtils.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +/* eslint-disable no-control-regex */ + +import { WriteStream } from 'tty'; +import { resolve as resolvePath } from 'path'; +import { paths } from '../../../paths'; + +export function mockPaths(options: { + ownDir?: string; + ownRoot?: string; + targetDir?: string; + targetRoot?: string; +}): void { + const { ownDir, ownRoot, targetDir, targetRoot } = options; + if (ownDir) { + paths.ownDir = ownDir; + jest + .spyOn(paths, 'resolveOwn') + .mockImplementation((...ps) => resolvePath(ownDir, ...ps)); + } + if (ownRoot) { + jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot); + jest + .spyOn(paths, 'resolveOwnRoot') + .mockImplementation((...ps) => resolvePath(ownRoot, ...ps)); + } + if (targetDir) { + paths.targetDir = targetDir; + jest + .spyOn(paths, 'resolveTarget') + .mockImplementation((...ps) => resolvePath(targetDir, ...ps)); + } + if (targetRoot) { + jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot); + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...ps) => resolvePath(targetRoot, ...ps)); + } +} + +export function createMockOutputStream() { + const output = new Array(); + return [ + output, + { + cursorTo: () => {}, + clearLine: () => {}, + moveCursor: () => {}, + write: (msg: string) => { + let clean = msg; + // Remove terminal color escape sequences + clean = clean.replace(/\x1B\[\d\dm/g, ''); + // Remove any non-ascii + clean = clean.replace(/[^\x00-\x7F]+/g, ''); + clean = clean.trim(); + output.push(clean); + }, + } as unknown as WriteStream & { fd: any }, + ] as const; +} diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.test.ts b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts new file mode 100644 index 0000000000..27ee14ea2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.test.ts @@ -0,0 +1,217 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { frontendPlugin } from './frontendPlugin'; + +const appTsxContent = ` +import { createApp } from '@backstage/app-defaults'; + +const router = ( + + } /> + +) +`; + +describe('frontendPlugin factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a frontend plugin', async () => { + mockFs({ + '/root': { + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test', + 'Checking Prerequisites:', + `availability plugins${sep}test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.tsx.hbs', + 'templating index.ts.hbs', + 'templating plugin.test.ts.hbs', + 'templating plugin.ts.hbs', + 'templating routes.ts.hbs', + 'copying setupTests.ts', + 'templating ExampleComponent.test.tsx.hbs', + 'templating ExampleComponent.tsx.hbs', + 'copying index.ts', + 'templating ExampleFetchComponent.test.tsx.hbs', + 'templating ExampleFetchComponent.tsx.hbs', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}test`, + 'app adding dependency', + 'app adding import', + ]); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + 'backstage-plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from 'backstage-plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); + + it('should create a frontend plugin with more options and codeowners', async () => { + mockFs({ + '/root': { + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, + }, + }, + }, + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(frontendPlugin, { + id: 'test', + owner: '@test-user', + }); + + const [, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await frontendPlugin.create(options, { + scope: 'internal', + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => {}, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + await expect( + fs.readJson('/root/packages/app/package.json'), + ).resolves.toEqual({ + dependencies: { + '@internal/plugin-test': '^1.0.0', + }, + }); + + await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves + .toBe(` +import { createApp } from '@backstage/app-defaults'; +import { TestPage } from '@internal/plugin-test'; + +const router = ( + + } /> + } /> + +) +`); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/frontendPlugin.ts b/packages/cli/src/lib/create/factories/frontendPlugin.ts new file mode 100644 index 0000000000..3862ae4883 --- /dev/null +++ b/packages/cli/src/lib/create/factories/frontendPlugin.ts @@ -0,0 +1,129 @@ +/* + * 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 fs from 'fs-extra'; +import chalk from 'chalk'; +import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { addPackageDependency, Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const frontendPlugin = createFactory({ + name: 'plugin', + description: 'A new frontend plugin', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + + const name = ctx.scope + ? `@${ctx.scope}/plugin-${id}` + : `backstage-plugin-${id}`; + const extensionName = `${upperFirst(camelCase(id))}Page`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', id) + : paths.resolveTargetRoot(`backstage-plugin-${id}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-plugin', + values: { + id, + name, + extensionName, + pluginVar: `${camelCase(id)}Plugin`, + pluginVersion: ctx.defaultVersion, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + }, + }); + + if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) { + await Task.forItem('app', 'adding dependency', async () => { + await addPackageDependency( + paths.resolveTargetRoot('packages/app/package.json'), + { + dependencies: { + [name]: `^${ctx.defaultVersion}`, + }, + }, + ); + }); + + await Task.forItem('app', 'adding import', async () => { + const pluginsFilePath = paths.resolveTargetRoot( + 'packages/app/src/App.tsx', + ); + if (!(await fs.pathExists(pluginsFilePath))) { + return; + } + + const content = await fs.readFile(pluginsFilePath, 'utf8'); + const revLines = content.split('\n').reverse(); + + const lastImportIndex = revLines.findIndex(line => + line.match(/ from ("|').*("|')/), + ); + const lastRouteIndex = revLines.findIndex(line => + line.match(/<\/FlatRoutes/), + ); + + if (lastImportIndex !== -1 && lastRouteIndex !== -1) { + const importLine = `import { ${extensionName} } from '${name}';`; + if (!content.includes(importLine)) { + revLines.splice(lastImportIndex, 0, importLine); + } + + const componentLine = `} />`; + if (!content.includes(componentLine)) { + const [indentation] = + revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; + revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine); + } + + const newContent = revLines.reverse().join('\n'); + await fs.writeFile(pluginsFilePath, newContent, 'utf8'); + } + }); + } + + if (options.owner) { + await addCodeownersEntry(`/plugins/${id}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/index.ts b/packages/cli/src/lib/create/factories/index.ts new file mode 100644 index 0000000000..0764d33e2c --- /dev/null +++ b/packages/cli/src/lib/create/factories/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { frontendPlugin } from './frontendPlugin'; +export { backendPlugin } from './backendPlugin'; +export { pluginCommon } from './pluginCommon'; +export { scaffolderModule } from './scaffolderModule'; diff --git a/packages/cli/src/lib/create/factories/pluginCommon.test.ts b/packages/cli/src/lib/create/factories/pluginCommon.test.ts new file mode 100644 index 0000000000..602fce7bbd --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.test.ts @@ -0,0 +1,108 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { pluginCommon } from './pluginCommon'; + +describe('pluginCommon factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a common plugin package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(pluginCommon, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await pluginCommon.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: () => fs.mkdtemp('test'), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating backend plugin backstage-plugin-test-common', + 'Checking Prerequisites:', + `availability plugins${sep}test-common`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying setupTests.ts', + 'Installing:', + `moving plugins${sep}test-common`, + ]); + + await expect( + fs.readJson('/root/plugins/test-common/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-test-common', + description: 'Common functionalities for the test plugin', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/test-common'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/pluginCommon.ts b/packages/cli/src/lib/create/factories/pluginCommon.ts new file mode 100644 index 0000000000..1bcca2fb6a --- /dev/null +++ b/packages/cli/src/lib/create/factories/pluginCommon.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 chalk from 'chalk'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt, pluginIdPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const pluginCommon = createFactory({ + name: 'plugin-common', + description: 'A new isomorphic common plugin package', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [pluginIdPrompt(), ownerPrompt()], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const suffix = `${id}-common`; + const name = ctx.scope + ? `@${ctx.scope}/plugin-${suffix}` + : `backstage-plugin-${suffix}`; + + Task.log(); + Task.log(`Creating backend plugin ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', suffix) + : paths.resolveTargetRoot(`backstage-plugin-${suffix}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'default-common-plugin-package', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${suffix}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.test.ts b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts new file mode 100644 index 0000000000..c60c58e619 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.test.ts @@ -0,0 +1,111 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { sep, resolve as resolvePath } from 'path'; +import { paths } from '../../paths'; +import { Task } from '../../tasks'; +import { FactoryRegistry } from '../FactoryRegistry'; +import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { scaffolderModule } from './scaffolderModule'; + +describe('scaffolderModule factory', () => { + beforeEach(() => { + mockPaths({ + targetRoot: '/root', + }); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should create a scaffolder backend module package', async () => { + mockFs({ + '/root': { + plugins: mockFs.directory(), + }, + [paths.resolveOwn('templates')]: mockFs.load( + paths.resolveOwn('templates'), + ), + }); + + const options = await FactoryRegistry.populateOptions(scaffolderModule, { + id: 'test', + }); + + let modified = false; + + const [output, mockStream] = createMockOutputStream(); + jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream); + jest.spyOn(Task, 'forCommand').mockResolvedValue(); + + await scaffolderModule.create(options, { + private: true, + isMonoRepo: true, + defaultVersion: '1.0.0', + markAsModified: () => { + modified = true; + }, + createTemporaryDirectory: (name: string) => fs.mkdtemp(name), + }); + + expect(modified).toBe(true); + + expect(output).toEqual([ + '', + 'Creating module backstage-plugin-scaffolder-backend-module-test', + 'Checking Prerequisites:', + `availability plugins${sep}scaffolder-backend-module-test`, + 'creating temp dir', + 'Executing Template:', + 'copying .eslintrc.js', + 'templating README.md.hbs', + 'templating package.json.hbs', + 'copying tsconfig.json', + 'templating index.ts.hbs', + 'copying index.ts', + 'copying example.test.ts', + 'copying example.ts', + 'copying index.ts', + 'Installing:', + `moving plugins${sep}scaffolder-backend-module-test`, + ]); + + await expect( + fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + ).resolves.toEqual( + expect.objectContaining({ + name: 'backstage-plugin-scaffolder-backend-module-test', + description: 'The test module for @backstage/plugin-scaffolder-backend', + private: true, + version: '1.0.0', + }), + ); + + expect(Task.forCommand).toHaveBeenCalledTimes(2); + expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { + cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + optional: true, + }); + }); +}); diff --git a/packages/cli/src/lib/create/factories/scaffolderModule.ts b/packages/cli/src/lib/create/factories/scaffolderModule.ts new file mode 100644 index 0000000000..b89f0fc691 --- /dev/null +++ b/packages/cli/src/lib/create/factories/scaffolderModule.ts @@ -0,0 +1,96 @@ +/* + * 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 chalk from 'chalk'; +import { paths } from '../../paths'; +import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners'; +import { createFactory, CreateContext } from '../types'; +import { Task } from '../../tasks'; +import { ownerPrompt } from './common/prompts'; +import { executePluginPackageTemplate } from './common/tasks'; + +type Options = { + id: string; + owner?: string; + codeOwnersPath?: string; +}; + +export const scaffolderModule = createFactory({ + name: 'scaffolder-module', + description: + 'An module exporting custom actions for @backstage/plugin-scaffolder-backend', + optionsDiscovery: async () => ({ + codeOwnersPath: await getCodeownersFilePath(paths.targetRoot), + }), + optionsPrompts: [ + { + type: 'input', + name: 'id', + message: 'Enter the name of the module [required]', + validate: (value: string) => { + if (!value) { + return 'Please enter the name of the module'; + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return 'Module names must be lowercase and contain only letters, digits, and dashes.'; + } + return true; + }, + }, + ownerPrompt(), + ], + async create(options: Options, ctx: CreateContext) { + const { id } = options; + const slug = `scaffolder-backend-module-${id}`; + + let name = `backstage-plugin-${slug}`; + if (ctx.scope) { + if (ctx.scope === 'backstage') { + name = `@backstage/plugin-${slug}`; + } else { + name = `@${ctx.scope}/backstage-plugin-${slug}`; + } + } + + Task.log(); + Task.log(`Creating module ${chalk.cyan(name)}`); + + const targetDir = ctx.isMonoRepo + ? paths.resolveTargetRoot('plugins', slug) + : paths.resolveTargetRoot(`backstage-plugin-${slug}`); + + await executePluginPackageTemplate(ctx, { + targetDir, + templateName: 'scaffolder-module', + values: { + id, + name, + privatePackage: ctx.private, + npmRegistry: ctx.npmRegistry, + pluginVersion: ctx.defaultVersion, + }, + }); + + if (options.owner) { + await addCodeownersEntry(`/plugins/${slug}`, options.owner); + } + + await Task.forCommand('yarn install', { cwd: targetDir, optional: true }); + await Task.forCommand('yarn lint --fix', { + cwd: targetDir, + optional: true, + }); + }, +}); diff --git a/packages/cli/src/lib/create/types.ts b/packages/cli/src/lib/create/types.ts new file mode 100644 index 0000000000..5a2460d0df --- /dev/null +++ b/packages/cli/src/lib/create/types.ts @@ -0,0 +1,77 @@ +/* + * 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 { DistinctQuestion } from 'inquirer'; + +export interface CreateContext { + /** The package scope to use for new packages */ + scope?: string; + /** The NPM registry to use for new packages */ + npmRegistry?: string; + /** Whether new packages should be marked as private */ + private: boolean; + /** Whether we are creating something in a monorepo or not */ + isMonoRepo: boolean; + /** The default version to use for new packages */ + defaultVersion: string; + + /** Creates a temporary directory. This will always be deleted after creation is done. */ + createTemporaryDirectory(name: string): Promise; + + /** Signal that the creation process got to a point where permanent modifications were made */ + markAsModified(): void; +} + +export type AnyOptions = Record; + +export type Prompt = DistinctQuestion & { name: string }; + +export interface Factory { + /** + * The name used for this factory. + */ + name: string; + + /** + * A description that describes what this factory creates to the user. + */ + description: string; + + /** + * An optional options discovery step that is run + * before the prompts to potentially fill in some of the options. + */ + optionsDiscovery?(): Promise>; + + /** + * Inquirer prompts that will be filled in either interactively or + * through command line arguments. + */ + optionsPrompts?: ReadonlyArray>; + + /** + * The main method of the factory that handles creation. + */ + create(options: TOptions, context?: CreateContext): Promise; +} + +export type AnyFactory = Factory; + +export function createFactory( + config: Factory, +): AnyFactory { + return config as AnyFactory; +} diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index dba42e6013..4faf904177 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -106,18 +106,19 @@ export async function createDistWorkspace( if (options.buildDependencies) { const exclude = options.buildExcludes ?? []; - const scopeArgs = targets - .filter(target => !exclude.includes(target.name)) - .flatMap(target => ['--scope', target.name]); - const lernaArgs = - options.parallel && Number.isInteger(options.parallel) - ? ['--concurrency', options.parallel.toString()] - : []; + const toBuild = targets.filter(target => !exclude.includes(target.name)); + if (toBuild.length > 0) { + const scopeArgs = toBuild.flatMap(target => ['--scope', target.name]); + const lernaArgs = + options.parallel && Number.isInteger(options.parallel) + ? ['--concurrency', options.parallel.toString()] + : []; - await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { - cwd: paths.targetRoot, - }); + await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { + cwd: paths.targetRoot, + }); + } } await moveToDistWorkspace(targetDir, targets); diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index e17015d6cf..c84eed435e 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -18,35 +18,40 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; +import { promisify } from 'util'; import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { exec as execCb } from 'child_process'; import { paths } from './paths'; +import { assertError } from '@backstage/errors'; + +const exec = promisify(execCb); const TASK_NAME_MAX_LENGTH = 14; export class Task { static log(name: string = '') { - process.stdout.write(`${chalk.green(name)}\n`); + process.stderr.write(`${chalk.green(name)}\n`); } static error(message: string = '') { - process.stdout.write(`\n${chalk.red(message)}\n\n`); + process.stderr.write(`\n${chalk.red(message)}\n\n`); } static section(name: string) { const title = chalk.green(`${name}:`); - process.stdout.write(`\n ${title}\n`); + process.stderr.write(`\n ${title}\n`); } static exit(code: number = 0) { process.exit(code); } - static async forItem( + static async forItem( task: string, item: string, - taskFunc: () => Promise, - ): Promise { + taskFunc: () => Promise, + ): Promise { const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH)); const spinner = ora({ @@ -56,13 +61,40 @@ export class Task { }).start(); try { - await taskFunc(); + const result = await taskFunc(); spinner.succeed(); + return result; } catch (error) { spinner.fail(); throw error; } } + + static async forCommand( + command: string, + options?: { cwd?: string; optional?: boolean }, + ) { + try { + await Task.forItem('executing', command, async () => { + await exec(command, { cwd: options?.cwd }); + }); + } catch (error) { + assertError(error); + if (error.stderr) { + process.stderr.write(error.stderr as Buffer); + } + if (error.stdout) { + process.stdout.write(error.stdout as Buffer); + } + if (options?.optional) { + Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`); + } else { + throw new Error( + `Failed to execute command '${chalk.cyan(command)}', ${error}`, + ); + } + } + } } export async function templatingTask( @@ -85,7 +117,9 @@ export async function templatingTask( const destination = destinationFile.replace(/\.hbs$/, ''); const template = await fs.readFile(file); - const compiled = handlebars.compile(template.toString()); + const compiled = handlebars.compile(template.toString(), { + strict: true, + }); const contents = compiled( { name: basename(destination), ...context }, { @@ -122,3 +156,44 @@ export async function templatingTask( } } } + +export async function addPackageDependency( + path: string, + options: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, +) { + try { + const pkgJson = await fs.readJson(path); + + const normalize = (obj: Record) => { + if (Object.keys(obj).length === 0) { + return undefined; + } + return Object.fromEntries( + Object.keys(obj) + .sort() + .map(key => [key, obj[key]]), + ); + }; + + pkgJson.dependencies = normalize({ + ...pkgJson.dependencies, + ...options.dependencies, + }); + pkgJson.devDependencies = normalize({ + ...pkgJson.devDependencies, + ...options.devDependencies, + }); + pkgJson.peerDependencies = normalize({ + ...pkgJson.peerDependencies, + ...options.peerDependencies, + }); + + await fs.writeJson(path, pkgJson, { spaces: 2 }); + } catch (error) { + throw new Error(`Failed to add package dependencies, ${error}`); + } +} diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index c69072c64d..3b512b22c3 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso import { version as devUtils } from '@backstage/dev-utils/package.json'; import { version as testUtils } from '@backstage/test-utils/package.json'; import { version as theme } from '@backstage/theme/package.json'; +import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; export const packageVersions: Record = { '@backstage/backend-common': backendCommon, @@ -53,6 +54,7 @@ export const packageVersions: Record = { '@backstage/dev-utils': devUtils, '@backstage/test-utils': testUtils, '@backstage/theme': theme, + '@backstage/plugin-scaffolder-backend': scaffolderBackend, }; export function findVersion() { diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 5cca7dcb07..37bcd94186 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -4,41 +4,43 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - {{#if privatePackage}} "private": {{privatePackage}}, - {{/if}} +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} "publishConfig": { - {{#if npmRegistry}} "registry": "{{npmRegistry}}", - {{/if}} - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "scripts": { - "start": "backstage-cli backend:dev", - "build": "backstage-cli backend:build", - "lint": "backstage-cli lint", - "test": "backstage-cli test", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": { - "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", - "@backstage/config": "{{versionQuery '@backstage/config'}}", - "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", - "express": "{{versionQuery 'express' '4.17.1'}}", - "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", - "winston": "{{versionQuery 'winston' '3.2.1'}}", - "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", - "yn": "{{versionQuery 'yn' '4.0.0'}}" - }, - "devDependencies": { - "@backstage/cli": "{{versionQuery '@backstage/cli'}}", - "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", - "supertest": "{{versionQuery 'supertest' '4.0.2'}}", - "msw": "{{versionQuery 'msw' '0.35.0'}}" - }, - "files": [ - "dist" - ] - } +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/config": "{{versionQuery '@backstage/config'}}", + "@types/express": "{{versionQuery '@types/express' '4.17.6'}}", + "express": "{{versionQuery 'express' '4.17.1'}}", + "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", + "winston": "{{versionQuery 'winston' '3.2.1'}}", + "cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}", + "yn": "{{versionQuery 'yn' '4.0.0'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", + "supertest": "{{versionQuery 'supertest' '4.0.2'}}", + "msw": "{{versionQuery 'msw' '0.35.0'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/.eslintrc.js b/packages/cli/templates/default-common-plugin-package/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/cli/templates/default-common-plugin-package/README.md.hbs b/packages/cli/templates/default-common-plugin-package/README.md.hbs new file mode 100644 index 0000000000..917e18d4b9 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +Welcome to the common package for the {{id}} plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/default-common-plugin-package/package.json.hbs b/packages/cli/templates/default-common-plugin-package/package.json.hbs new file mode 100644 index 0000000000..efaee496e7 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/package.json.hbs @@ -0,0 +1,34 @@ +{ + "name": "{{name}}", + "description": "Common functionalities for the {{id}} plugin", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs new file mode 100644 index 0000000000..2e1150d74e --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/index.ts.hbs @@ -0,0 +1,19 @@ +/***/ +/** + * Common functionalities for the {{id}} plugin. + * + * @packageDocumentation + */ + +/** + * In this package you might for example declare types that are common + * between the frontend and backend plugin packages. + */ +export type CommonType = { + field: string +} + +/** + * Or you might declare some common constants. + */ +export const COMMON_CONSTANT = 1 diff --git a/packages/cli/templates/default-common-plugin-package/src/setupTests.ts b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli/templates/default-common-plugin-package/tsconfig.json b/packages/cli/templates/default-common-plugin-package/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/default-common-plugin-package/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 41376821ba..624302da93 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -4,10 +4,12 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", -{{#if privatePackage}} "private": {{privatePackage}}, +{{#if privatePackage}} + "private": {{privatePackage}}, {{/if}} "publishConfig": { -{{#if npmRegistry}} "registry": "{{npmRegistry}}", +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", {{/if}} "access": "public", "main": "dist/index.esm.js", diff --git a/packages/cli/templates/scaffolder-module/.eslintrc.js b/packages/cli/templates/scaffolder-module/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/cli/templates/scaffolder-module/README.md.hbs b/packages/cli/templates/scaffolder-module/README.md.hbs new file mode 100644 index 0000000000..8ee653a4ac --- /dev/null +++ b/packages/cli/templates/scaffolder-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{name}} + +The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend). + +_This plugin was created through the Backstage CLI_ diff --git a/packages/cli/templates/scaffolder-module/package.json.hbs b/packages/cli/templates/scaffolder-module/package.json.hbs new file mode 100644 index 0000000000..de6ffd9e3a --- /dev/null +++ b/packages/cli/templates/scaffolder-module/package.json.hbs @@ -0,0 +1,37 @@ +{ + "name": "{{name}}", + "description": "The {{id}} module for @backstage/plugin-scaffolder-backend", + "version": "{{pluginVersion}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", +{{#if privatePackage}} + "private": {{privatePackage}}, +{{/if}} + "publishConfig": { +{{#if npmRegistry}} + "registry": "{{npmRegistry}}", +{{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build --output cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}" + }, + "devDependencies": { + "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts new file mode 100644 index 0000000000..e427b2c603 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { PassThrough } from 'stream'; +import { createAcmeExampleAction } from './example'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('acme:example', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should call action', async () => { + const action = createAcmeExampleAction(); + + const logger = getVoidLogger(); + jest.spyOn(logger, 'info'); + + await action.handler({ + input: { + myParameter: 'test', + }, + workspacePath: '/tmp', + logger, + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory() { + // Usage of mock-fs is recommended for testing of filesystem operations + throw new Error('Not implemented'); + }, + }); + + expect(logger.info).toHaveBeenCalledWith( + 'Running example template with parameters: test', + ); + }); +}); diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts new file mode 100644 index 0000000000..c20a4bdf25 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.ts @@ -0,0 +1,57 @@ +/* + * 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 '@backstage/plugin-scaffolder-backend'; + +/** + * Creates an `acme:example` Scaffolder action. + * + * @remarks + * + * See {@link https://example.com} for more information. + * + * @public + */ +export function createAcmeExampleAction() { + // For more information on how to define custom actions, see + // https://backstage.io/docs/features/software-templates/writing-custom-actions + return createTemplateAction<{ + myParameter: string; + }>({ + id: 'acme:example', + description: 'Runs Yeoman on an installed Yeoman generator', + schema: { + input: { + type: 'object', + required: ['myParameter'], + properties: { + myParameter: { + title: 'An example parameter', + description: 'This is the schema for our example parameter', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + ctx.logger.info( + `Running example template with parameters: ${ctx.input.myParameter}`, + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + }, + }); +} diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/index.ts b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts new file mode 100644 index 0000000000..e81099f333 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/example/index.ts @@ -0,0 +1 @@ +export { createAcmeExampleAction } from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/actions/index.ts b/packages/cli/templates/scaffolder-module/src/actions/index.ts new file mode 100644 index 0000000000..ab6642ebb0 --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/actions/index.ts @@ -0,0 +1 @@ +export * from './example'; diff --git a/packages/cli/templates/scaffolder-module/src/index.ts.hbs b/packages/cli/templates/scaffolder-module/src/index.ts.hbs new file mode 100644 index 0000000000..3690e43b8e --- /dev/null +++ b/packages/cli/templates/scaffolder-module/src/index.ts.hbs @@ -0,0 +1,8 @@ +/***/ +/** + * The {{id}} module for @backstage/plugin-scaffolder-backend. + * + * @packageDocumentation + */ + +export * from './actions'; diff --git a/packages/cli/templates/scaffolder-module/tsconfig.json b/packages/cli/templates/scaffolder-module/tsconfig.json new file mode 100644 index 0000000000..5ae9aeb62d --- /dev/null +++ b/packages/cli/templates/scaffolder-module/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 6f568df942..e0fab50e5b 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -38,7 +38,9 @@ export type ConfigTarget = export type ConfigVisibility = 'frontend' | 'backend' | 'secret'; // @public -export function loadConfig(options: LoadConfigOptions): Promise; +export function loadConfig( + options: LoadConfigOptions, +): Promise; // @public export type LoadConfigOptions = { @@ -66,6 +68,11 @@ export type LoadConfigOptionsWatch = { stopSignal?: Promise; }; +// @public +export type LoadConfigResult = { + appConfigs: AppConfig[]; +}; + // @public export function loadConfigSchema( options: LoadConfigSchemaOptions, diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 2e25d91856..76dcec35c6 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -38,7 +38,7 @@ "ajv": "^7.0.3", "chokidar": "^3.5.2", "fs-extra": "9.1.0", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", "typescript-json-schema": "^0.51.0", diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 97f0d301a3..2699b3571f 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -34,4 +34,5 @@ export type { LoadConfigOptions, LoadConfigOptionsWatch, LoadConfigOptionsRemote, + LoadConfigResult, } from './loader'; diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 804254afa0..b5c986eeb9 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -122,18 +122,20 @@ describe('loadConfig', () => { configTargets: [], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('load config from remote path', async () => { @@ -151,18 +153,20 @@ describe('loadConfig', () => { reloadIntervalSeconds: 30, }, }), - ).resolves.toEqual([ - { - context: configUrl, - data: { - app: { - title: 'Remote Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads config with secrets from two different files', async () => { @@ -173,28 +177,30 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.yaml' }], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - { - context: 'app-config2.yaml', - data: { - app: { - title: 'Example App 2', - sessionKey: 'abc123', - escaped: '${Escaped}', + { + context: 'app-config2.yaml', + data: { + app: { + title: 'Example App 2', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads config with secrets from single file', async () => { @@ -205,18 +211,20 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.yaml' }], env: 'production', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); }); it('loads development config with secrets', async () => { @@ -230,34 +238,36 @@ describe('loadConfig', () => { ], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', - }, - }, - }, - { - context: 'app-config.development.yaml', - data: { - app: { - sessionKey: 'development-key', - }, - backend: { - foo: { - bar: 'token is-secret', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', }, }, - other: { - secret: 'abc123', + }, + { + context: 'app-config.development.yaml', + data: { + app: { + sessionKey: 'development-key', + }, + backend: { + foo: { + bar: 'token is-secret', + }, + }, + other: { + secret: 'abc123', + }, }, }, - }, - ]); + ], + }); }); it('loads deep substituted config', async () => { @@ -268,19 +278,21 @@ describe('loadConfig', () => { configTargets: [{ path: '/root/app-config.substitute.yaml' }], env: 'development', }), - ).resolves.toEqual([ - { - context: 'app-config.substitute.yaml', - data: { - app: { - someConfig: { - secret: '123abc', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.substitute.yaml', + data: { + app: { + someConfig: { + secret: '123abc', + }, + noSubstitute: 'notSubstituted', }, - noSubstitute: 'notSubstituted', }, }, - }, - ]); + ], + }); }); it('watches config files', async () => { @@ -297,18 +309,20 @@ describe('loadConfig', () => { stopSignal: stopSignal.promise, }, }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); await fs.writeJson('/root/app-config.yaml', { app: { @@ -349,18 +363,20 @@ describe('loadConfig', () => { reloadIntervalSeconds: 1, }, }), - ).resolves.toEqual([ - { - context: configUrl, - data: { - app: { - title: 'Remote Example App', - sessionKey: 'abc123', - escaped: '${Escaped}', + ).resolves.toEqual({ + appConfigs: [ + { + context: configUrl, + data: { + app: { + title: 'Remote Example App', + sessionKey: 'abc123', + escaped: '${Escaped}', + }, }, }, - }, - ]); + ], + }); server.use(reloadHandler); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 4a5660f46c..6a92ed519d 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -88,6 +88,17 @@ export type LoadConfigOptions = { watch?: LoadConfigOptionsWatch; }; +/** + * Results of loading configuration files. + * @public + */ +export type LoadConfigResult = { + /** + * Array of all loaded configs. + */ + appConfigs: AppConfig[]; +}; + /** * Load configuration data. * @@ -95,7 +106,7 @@ export type LoadConfigOptions = { */ export async function loadConfig( options: LoadConfigOptions, -): Promise { +): Promise { const { configRoot, experimentalEnvFunc: envFunc, watch, remote } = options; const configPaths: string[] = options.configTargets @@ -290,7 +301,9 @@ export async function loadConfig( watchRemoteConfig(watch, remote); } - return remote - ? [...remoteConfigs, ...fileConfigs, ...envConfigs] - : [...fileConfigs, ...envConfigs]; + return { + appConfigs: remote + ? [...remoteConfigs, ...fileConfigs, ...envConfigs] + : [...fileConfigs, ...envConfigs], + }; } diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index d98b5ef5f8..a67d2a2225 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -28,9 +28,9 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { Error as Error_2 } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FeatureFlag } from '@backstage/core-plugin-api'; import { FeatureFlagsApi } from '@backstage/core-plugin-api'; @@ -95,7 +95,6 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { | undefined; // (undocumented) getAllApis(): Set; - // Warning: (ae-forgotten-export) The symbol "ApiFactoryScope" needs to be exported by the entry point index.d.ts register< Api, Impl extends Api, @@ -105,6 +104,9 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { >(scope: ApiFactoryScope, factory: ApiFactory): boolean; } +// @public +export type ApiFactoryScope = 'default' | 'app' | 'static'; + // @public export const ApiProvider: { (props: PropsWithChildren): JSX.Element; @@ -118,6 +120,12 @@ export const ApiProvider: { }; }; +// @public +export type ApiProviderProps = { + apis: ApiHolder; + children: ReactNode; +}; + // @public export class ApiRegistry implements ApiHolder { constructor(apis: Map); @@ -197,17 +205,20 @@ export type AppOptions = { icons: AppIcons & { [key in string]: IconComponent; }; - plugins?: BackstagePluginWithAnyOutput[]; + plugins?: (Omit, 'output'> & { + output(): ( + | PluginOutput + | { + type: string; + } + )[]; + })[]; components: AppComponents; themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; bindRoutes?(context: { bind: AppRouteBinder }): void; }; -// Warning: (ae-forgotten-export) The symbol "PartialKeys" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "TargetRouteMap" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "KeysWithType" needs to be exported by the entry point index.d.ts -// // @public export type AppRouteBinder = < ExternalRoutes extends { @@ -238,8 +249,6 @@ export class AppThemeSelector implements AppThemeApi { // @public export class AtlassianAuth { - // Warning: (ae-forgotten-export) The symbol "OAuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -261,6 +270,15 @@ export class Auth0Auth { }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T; } +// @public +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export type BackstageApp = { getPlugins(): BackstagePlugin[]; @@ -269,7 +287,7 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public +// @public @deprecated export type BackstagePluginWithAnyOutput = Omit< BackstagePlugin, 'output' @@ -327,11 +345,11 @@ export class ErrorAlerter implements ErrorApi { constructor(alertApi: AlertApi, errorApi: ErrorApi); // (undocumented) error$(): Observable<{ - error: Error_2; - context?: ErrorContext | undefined; + error: ErrorApiError; + context?: ErrorApiErrorContext | undefined; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -339,10 +357,10 @@ export class ErrorApiForwarder implements ErrorApi { // (undocumented) error$(): Observable<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; } // @public @@ -378,6 +396,8 @@ export type FlatRoutesProps = { // @public export class GithubAuth implements OAuthApi, SessionApi { // Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts + // + // @deprecated constructor(sessionManager: SessionManager); // (undocumented) static create({ @@ -479,12 +499,11 @@ export class OAuth2 BackstageIdentityApi, SessionApi { + // @deprecated constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }); - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -493,7 +512,7 @@ export class OAuth2 oauthRequestApi, defaultScopes, scopeTransform, - }: CreateOptions): OAuth2; + }: OAuth2CreateOptions): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -515,6 +534,11 @@ export class OAuth2 signOut(): Promise; } +// @public +export type OAuth2CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; +}; + // @public export type OAuth2Session = { providerInfo: { @@ -527,6 +551,12 @@ export type OAuth2Session = { backstageIdentity: BackstageIdentity; }; +// @public +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + // @public export class OAuthRequestManager implements OAuthRequestApi { // (undocumented) @@ -549,25 +579,31 @@ export class OktaAuth { // @public export class OneLoginAuth { - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, environment, provider, oauthRequestApi, - }: CreateOptions_2): typeof oneloginAuthApiRef.T; + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T; } +// @public +export type OneLoginAuthCreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + environment?: string; + provider?: AuthProvider & { + id: string; + }; +}; + // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - // Warning: (ae-forgotten-export) The symbol "SamlSession" needs to be exported by the entry point index.d.ts + // @deprecated constructor(sessionManager: SessionManager); - // Warning: (ae-forgotten-export) The symbol "AuthApiCreateOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static create({ discoveryApi, @@ -588,6 +624,13 @@ export class SamlAuth signOut(): Promise; } +// @public +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // @public export type SignInPageProps = { onResult(result: SignInResult): void; @@ -603,7 +646,7 @@ export type SignInResult = { // @public export class UnhandledErrorForwarder { - static forward(errorApi: ErrorApi, errorContext: ErrorContext): void; + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void; } // @public @@ -632,8 +675,4 @@ export class WebStorage implements StorageApi { // (undocumented) set(key: string, data: T): Promise; } - -// Warnings were encountered during analysis: -// -// src/apis/system/ApiProvider.d.ts:15:5 - (ae-forgotten-export) The symbol "ApiProviderProps" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts index 2111798d6a..350213d938 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ErrorApi, ErrorContext, AlertApi } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, + AlertApi, +} from '@backstage/core-plugin-api'; /** * Decorates an ErrorApi by also forwarding error messages @@ -27,7 +32,7 @@ export class ErrorAlerter implements ErrorApi { private readonly errorApi: ErrorApi, ) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (!context?.hidden) { this.alertApi.post({ message: error.message, severity: 'error' }); } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index cd4564a050..f67c00d991 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { PublishSubject } from '../../../lib/subjects'; @@ -26,14 +30,14 @@ import { PublishSubject } from '../../../lib/subjects'; export class ErrorApiForwarder implements ErrorApi { private readonly subject = new PublishSubject<{ error: Error; - context?: ErrorContext; + context?: ErrorApiErrorContext; }>(); - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { this.subject.next({ error, context }); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ error: Error; context?: ErrorApiErrorContext }> { return this.subject; } } diff --git a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts index 16e473fb3a..8e697f12b2 100644 --- a/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/ErrorApi/UnhandledErrorForwarder.ts @@ -1,4 +1,8 @@ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; /* * Copyright 2020 Spotify AB @@ -25,11 +29,11 @@ export class UnhandledErrorForwarder { /** * Add event listener, such that unhandled errors can be forwarded using an given `ErrorApi` instance */ - static forward(errorApi: ErrorApi, errorContext: ErrorContext) { + static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext) { window.addEventListener( 'unhandledrejection', (e: PromiseRejectionEvent) => { - errorApi.post(e.reason as Error, errorContext); + errorApi.post(e.reason as ErrorApiError, errorContext); }, ); } diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts index 04bfff028d..8bcd4cb7a5 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -21,7 +21,7 @@ describe('GithubAuth', () => { const getSession = jest .fn() .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const githubAuth = new GithubAuth({ getSession } as any); + const githubAuth = new (GithubAuth as any)({ getSession }) as GithubAuth; expect(await githubAuth.getAccessToken()).toBe('access-token'); expect(getSession).toBeCalledTimes(1); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts index 885e80da7c..3e9c899346 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -116,6 +116,9 @@ export default class GithubAuth implements OAuthApi, SessionApi { return new GithubAuth(sessionManagerMux); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index bbc9d23ccc..50333f07a0 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -25,3 +25,4 @@ export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; export * from './atlassian'; +export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 45937a68f8..403e8445d8 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -32,7 +32,11 @@ import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; -type CreateOptions = OAuthApiCreateOptions & { +/** + * OAuth2 create options. + * @public + */ +export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; }; @@ -73,7 +77,7 @@ export default class OAuth2 oauthRequestApi, defaultScopes = [], scopeTransform = x => x, - }: CreateOptions) { + }: OAuth2CreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -114,6 +118,9 @@ export default class OAuth2 private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts index 4ada35846a..fb8b0e6c64 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -16,6 +16,7 @@ import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; +export type { OAuth2CreateOptions } from './OAuth2'; /** * Session information for generic OAuth2 auth. * diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts index 5f933b9c6e..93b9f6634c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -22,7 +22,11 @@ import { } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; -type CreateOptions = { +/** + * OneLogin auth provider create options. + * @public + */ +export type OneLoginAuthCreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; @@ -58,7 +62,7 @@ export default class OneLoginAuth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof oneloginAuthApiRef.T { + }: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts index e1826f17dd..8504d95611 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/index.ts @@ -15,3 +15,4 @@ */ export { default as OneLoginAuth } from './OneLoginAuth'; +export type { OneLoginAuthCreateOptions } from './OneLoginAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index 63985979de..c1b70e963d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -79,6 +79,9 @@ export default class SamlAuth return this.sessionManager.sessionState$(); } + /** + * @deprecated will be made private in the future. Use create method instead. + */ constructor(private readonly sessionManager: SessionManager) {} async signIn() { diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts index 930e6cb115..f9dc2895af 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { default as SamlAuth } from './SamlAuth'; +export type { SamlSession } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/types.ts b/packages/core-app-api/src/apis/implementations/auth/types.ts index 89343e9e06..825f433cec 100644 --- a/packages/core-app-api/src/apis/implementations/auth/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/types.ts @@ -20,11 +20,19 @@ import { OAuthRequestApi, } from '@backstage/core-plugin-api'; +/** + * Create options for OAuth APIs. + * @public + */ export type OAuthApiCreateOptions = AuthApiCreateOptions & { oauthRequestApi: OAuthRequestApi; defaultScopes?: string[]; }; +/** + * Generic create options for auth APIs. + * @public + */ export type AuthApiCreateOptions = { discoveryApi: DiscoveryApi; environment?: string; diff --git a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts index 880f075930..5f56793cae 100644 --- a/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-app-api/src/apis/system/ApiFactoryRegistry.ts @@ -22,7 +22,11 @@ import { AnyApiFactory, } from '@backstage/core-plugin-api'; -type ApiFactoryScope = +/** + * Scope type when registering API factories. + * @public + */ +export type ApiFactoryScope = | 'default' // Default factories registered by core and plugins | 'app' // Factories registered in the app, overriding default ones | 'static'; // APIs that can't be overridden, e.g. config diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx index 73bbdd6f77..c75f883a51 100644 --- a/packages/core-app-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -23,7 +23,11 @@ import { createVersionedContext, } from '@backstage/version-bridge'; -type ApiProviderProps = { +/** + * Prop types for the ApiProvider component. + * @public + */ +export type ApiProviderProps = { apis: ApiHolder; children: ReactNode; }; diff --git a/packages/core-app-api/src/apis/system/index.ts b/packages/core-app-api/src/apis/system/index.ts index 23e1a9a4b8..56c42f1e2a 100644 --- a/packages/core-app-api/src/apis/system/index.ts +++ b/packages/core-app-api/src/apis/system/index.ts @@ -15,7 +15,9 @@ */ export { ApiProvider } from './ApiProvider'; +export type { ApiProviderProps } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; export { ApiResolver } from './ApiResolver'; export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export type { ApiFactoryScope } from './ApiFactoryRegistry'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index f2d1976f87..f16c8ac656 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -152,6 +152,8 @@ export type AppConfigLoader = () => Promise; /** * Extracts a union of the keys in a map whose value extends the given type + * + * @ignore */ type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; @@ -159,6 +161,8 @@ type KeysWithType = { /** * Takes a map Map required values and makes all keys matching Keys optional + * + * @ignore */ type PartialKeys< Map extends { [name in string]: any }, @@ -167,6 +171,8 @@ type PartialKeys< /** * Creates a map of target routes with matching parameters based on a map of external routes. + * + * @ignore */ type TargetRouteMap< ExternalRoutes extends { [name: string]: ExternalRouteRef }, @@ -200,6 +206,7 @@ export type AppRouteBinder = < * * @public * @remarks + * @deprecated Will be removed * * The `type: string` type is there to handle output from newer or older plugin * API versions that might not be supported by this version of the app API, but @@ -246,7 +253,9 @@ export type AppOptions = { /** * A list of all plugins to include in the app. */ - plugins?: BackstagePluginWithAnyOutput[]; + plugins?: (Omit, 'output'> & { + output(): (PluginOutput | { type: string })[]; + })[]; /** * Supply components to the app to override the default ones. diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 40495ca0e0..76d346031e 100644 --- a/packages/core-components/src/layout/Header/Header.tsx +++ b/packages/core-components/src/layout/Header/Header.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles( gridArea: 'pageHeader', padding: theme.spacing(3), width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + boxShadow: theme.shadows[4], position: 'relative', zIndex: 100, display: 'flex', @@ -65,12 +65,12 @@ const useStyles = makeStyles( title: { color: theme.palette.bursts.fontColor, wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + fontSize: theme.typography.h3.fontSize, marginBottom: 0, }, subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', + color: theme.palette.common.white, + opacity: 0.8, display: 'inline-block', // prevents margin collapse of adjacent siblings marginTop: theme.spacing(1), }, @@ -82,7 +82,6 @@ const useStyles = makeStyles( color: theme.palette.bursts.fontColor, }, breadcrumb: { - fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', color: theme.palette.bursts.fontColor, }, breadcrumbType: { diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index ed089ad6a1..3f15ae4ea6 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -293,11 +293,13 @@ export const SidebarItem = forwardRef((props, ref) => { type SidebarSearchFieldProps = { onSearch: (input: string) => void; to?: string; + icon?: IconComponent; }; export function SidebarSearchField(props: SidebarSearchFieldProps) { const [input, setInput] = useState(''); const classes = useStyles(); + const Icon = props.icon ? props.icon : SearchIcon; const search = () => { props.onSearch(input); @@ -329,7 +331,7 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) { return (
- + { userId: identity!.id, profile: profile!, getIdToken: () => { - return authApi.getBackstageIdentity().then(i => i!.idToken); + return authApi + .getBackstageIdentity() + .then(i => i!.token ?? i!.idToken); }, signOut: async () => { await authApi.signOut(); diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2765b02bca..27f3480930 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -404,23 +404,31 @@ export interface ElementCollection { }): ElementCollection; } -// @public -type Error_2 = { - name: string; - message: string; - stack?: string; -}; +// @public @deprecated (undocumented) +type Error_2 = ErrorApiError; export { Error_2 as Error }; // @public export type ErrorApi = { - post(error: Error_2, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; error$(): Observable_2<{ - error: Error_2; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; }; +// @public +export type ErrorApiError = { + name: string; + message: string; + stack?: string; +}; + +// @public +export type ErrorApiErrorContext = { + hidden?: boolean; +}; + // @public export const errorApiRef: ApiRef; @@ -431,10 +439,8 @@ export type ErrorBoundaryFallbackProps = { resetError: () => void; }; -// @public -export type ErrorContext = { - hidden?: boolean; -}; +// @public @deprecated (undocumented) +export type ErrorContext = ErrorApiErrorContext; // @public export type Extension = { @@ -703,14 +709,6 @@ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; -// @public -export type RouteOptions = { - exact?: boolean; -}; - -// @public -export type RoutePath = string; - // @public export type RouteRef = { $$routeRefType: 'absolute'; diff --git a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts index 820660ac52..45ee0901c8 100644 --- a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts @@ -23,22 +23,34 @@ import { Observable } from '@backstage/types'; * * @public */ -export type Error = { +export type ErrorApiError = { name: string; message: string; stack?: string; }; +/** + * @public + * @deprecated Use ErrorApiError instead + */ +export type Error = ErrorApiError; + /** * Provides additional information about an error that was posted to the application. * * @public */ -export type ErrorContext = { +export type ErrorApiErrorContext = { // If set to true, this error should not be displayed to the user. Defaults to false. hidden?: boolean; }; +/** + * @public + * @deprecated Use ErrorApiErrorContext instead + */ +export type ErrorContext = ErrorApiErrorContext; + /** * The error API is used to report errors to the app, and display them to the user. * @@ -62,12 +74,15 @@ export type ErrorApi = { /** * Post an error for handling by the application. */ - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; /** * Observe errors posted by other parts of the application. */ - error$(): Observable<{ error: Error; context?: ErrorContext }>; + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }>; }; /** diff --git a/packages/core-plugin-api/src/plugin/index.ts b/packages/core-plugin-api/src/plugin/index.ts index 0cb9a2aede..d3272607ef 100644 --- a/packages/core-plugin-api/src/plugin/index.ts +++ b/packages/core-plugin-api/src/plugin/index.ts @@ -25,6 +25,4 @@ export type { PluginConfig, PluginHooks, PluginOutput, - RouteOptions, - RoutePath, } from './types'; diff --git a/packages/core-plugin-api/src/plugin/types.ts b/packages/core-plugin-api/src/plugin/types.ts index 192e771092..aeb7037c51 100644 --- a/packages/core-plugin-api/src/plugin/types.ts +++ b/packages/core-plugin-api/src/plugin/types.ts @@ -17,23 +17,6 @@ import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; -/** - * Route configuration. - * - * @public - */ -export type RouteOptions = { - // Whether the route path must match exactly, defaults to true. - exact?: boolean; -}; - -/** - * Type alias for paths. - * - * @public - */ -export type RoutePath = string; - /** * Replace with using {@link RouteRef}s. * diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index a926b775e6..26e63c8f3a 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -23,7 +23,6 @@ import { } from './types'; import { OldIconComponent } from '../icons/types'; -// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused /** * @deprecated * @internal @@ -49,21 +48,45 @@ export class RouteRefImpl private readonly id: string, readonly params: ParamKeys, private readonly config: { + /** @deprecated */ path?: string; + /** @deprecated */ icon?: OldIconComponent; + /** @deprecated */ title?: string; }, - ) {} + ) { + if (config.path) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own path, please remove the path for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + + if (config.icon) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own icon, please remove the icon for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + + if (config.title) { + // eslint-disable-next-line no-console + console.warn( + `[core-plugin-api] - routeRefs no longer decide their own title, please remove the title for ${this.toString()}. This will be removed in upcoming versions.`, + ); + } + } + + /** @deprecated use `useRouteRef` instead */ + get path() { + return this.config.path ?? ''; + } get icon() { return this.config.icon; } - // TODO(Rugvip): Remove this, routes are looked up via the registry instead - get path() { - return this.config.path ?? ''; - } - get title() { return this.config.title ?? this.id; } diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 2820d41ed6..7c1feec683 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -23,6 +23,7 @@ "scripts": { "build": "backstage-cli build --outputs cjs", "lint": "backstage-cli lint", + "test": "backstage-cli test", "clean": "backstage-cli clean", "start": "nodemon --" }, @@ -40,6 +41,7 @@ "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", "@types/recursive-readdir": "^2.2.0", + "mock-fs": "^5.1.1", "ts-node": "^10.0.0" }, "peerDependencies": { diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts new file mode 100644 index 0000000000..ee01e8fd42 --- /dev/null +++ b/packages/create-app/src/createApp.test.ts @@ -0,0 +1,83 @@ +/* + * 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 inquirer from 'inquirer'; +import mockFs from 'mock-fs'; +import path from 'path'; +import { Command } from 'commander'; +import * as tasks from './lib/tasks'; +import createApp from './createApp'; + +jest.mock('./lib/tasks'); + +beforeAll(() => { + mockFs({ + [`${__dirname}/package.json`]: '', // required by `findPaths(__dirname)` + 'templates/': mockFs.load(path.resolve(__dirname, '../templates/')), + }); +}); + +afterAll(() => { + mockFs.restore(); +}); + +const promptMock = jest.spyOn(inquirer, 'prompt'); +const checkPathExistsMock = jest.spyOn(tasks, 'checkPathExistsTask'); +const templatingMock = jest.spyOn(tasks, 'templatingTask'); +const checkAppExistsMock = jest.spyOn(tasks, 'checkAppExistsTask'); +const createTemporaryAppFolderMock = jest.spyOn( + tasks, + 'createTemporaryAppFolderTask', +); +const moveAppMock = jest.spyOn(tasks, 'moveAppTask'); +const buildAppMock = jest.spyOn(tasks, 'buildAppTask'); + +describe('command entrypoint', () => { + beforeEach(() => { + promptMock.mockResolvedValueOnce({ + name: 'MyApp', + dbType: 'PostgreSQL', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should call expected tasks with no path option', async () => { + const cmd = {} as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(checkAppExistsMock).toHaveBeenCalled(); + expect(createTemporaryAppFolderMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(moveAppMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should call expected tasks with path option', async () => { + const cmd = { path: 'myDirectory' } as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(checkPathExistsMock).toHaveBeenCalled(); + expect(templatingMock).toHaveBeenCalled(); + expect(buildAppMock).toHaveBeenCalled(); + }); + + it('should not call `buildAppTask` when `skipInstall` is supplied', async () => { + const cmd = { skipInstall: true } as unknown as Command; + await createApp(cmd, '1.0.0'); + expect(buildAppMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index d9719742fb..d301792380 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -14,87 +14,23 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { promisify } from 'util'; import chalk from 'chalk'; import { Command } from 'commander'; import inquirer, { Answers, Question } from 'inquirer'; -import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import { findPaths } from '@backstage/cli-common'; import os from 'os'; -import { Task, templatingTask } from './lib/tasks'; +import { + Task, + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './lib/tasks'; -const exec = promisify(execCb); - -async function checkAppExists(rootDir: string, name: string) { - await Task.forItem('checking', name, async () => { - const destination = resolvePath(rootDir, name); - - if (await fs.pathExists(destination)) { - const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); - throw new Error( - `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, - ); - } - }); -} - -async function checkPathExists(path: string) { - await Task.forItem('checking', path, async () => { - try { - await fs.mkdirs(path); - } catch (error) { - // will fail if a file already exists at given `path` - throw new Error(`Failed to create app directory: ${error.message}`); - } - }); -} - -async function createTemporaryAppFolder(tempDir: string) { - await Task.forItem('creating', 'temporary directory', async () => { - try { - await fs.mkdir(tempDir); - } catch (error) { - throw new Error(`Failed to create temporary app directory, ${error}`); - } - }); -} - -async function buildApp(appDir: string) { - const runCmd = async (cmd: string) => { - await Task.forItem('executing', cmd, async () => { - process.chdir(appDir); - - await exec(cmd).catch(error => { - process.stdout.write(error.stderr); - process.stdout.write(error.stdout); - throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); - }); - }); - }; - - await runCmd('yarn install'); - await runCmd('yarn tsc'); -} - -async function moveApp(tempDir: string, destination: string, id: string) { - await Task.forItem('moving', id, async () => { - await fs - .move(tempDir, destination) - .catch(error => { - throw new Error( - `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, - ); - }) - .finally(() => { - // remove temporary files on both success and failure - fs.removeSync(tempDir); - }); - }); -} - -export default async (cmd: Command): Promise => { +export default async (cmd: Command, version: string): Promise => { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); @@ -143,29 +79,29 @@ export default async (cmd: Command): Promise => { // Template directly to specified path Task.section('Checking that supplied path exists'); - await checkPathExists(appDir); + await checkPathExistsTask(appDir); Task.section('Preparing files'); - await templatingTask(templateDir, cmd.path, answers); + await templatingTask(templateDir, cmd.path, answers, version); } else { // Template to temporary location, and then move files Task.section('Checking if the directory is available'); - await checkAppExists(paths.targetDir, answers.name); + await checkAppExistsTask(paths.targetDir, answers.name); Task.section('Creating a temporary app directory'); - await createTemporaryAppFolder(tempDir); + await createTemporaryAppFolderTask(tempDir); Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, answers); + await templatingTask(templateDir, tempDir, answers, version); Task.section('Moving to final location'); - await moveApp(tempDir, appDir, answers.name); + await moveAppTask(tempDir, appDir, answers.name); } if (!cmd.skipInstall) { Task.section('Building the app'); - await buildApp(appDir); + await buildAppTask(appDir); } Task.log(); diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 1f37be56d2..8a2da2fad8 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -38,7 +38,7 @@ const main = (argv: string[]) => { '--skip-install', 'Skip the install and builds steps after creating the app', ) - .action(createApp); + .action(cmd => createApp(cmd, version)); program.parse(argv); }; diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts new file mode 100644 index 0000000000..24df97c694 --- /dev/null +++ b/packages/create-app/src/lib/tasks.test.ts @@ -0,0 +1,213 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import child_process from 'child_process'; +import path from 'path'; +import { + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryAppFolderTask, + moveAppTask, + templatingTask, +} from './tasks'; + +jest.mock('child_process'); + +beforeEach(() => { + mockFs({ + 'projects/my-module.ts': '', + 'projects/dir/my-file.txt': '', + 'tmp/mockApp/.gitignore': '', + 'tmp/mockApp/package.json': '', + 'tmp/mockApp/packages/app/package.json': '', + // load templates into mock filesystem + 'templates/': mockFs.load(path.resolve(__dirname, '../../templates/')), + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe('checkAppExistsTask', () => { + it('should do nothing if the directory does not exist', async () => { + const dir = 'projects/'; + const name = 'MyNewApp'; + await expect(checkAppExistsTask(dir, name)).resolves.not.toThrow(); + }); + + it('should throw an error when a file of the same name exists', async () => { + const dir = 'projects/'; + const name = 'my-module.ts'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); + + it('should throw an error when a directory of the same name exists', async () => { + const dir = 'projects/'; + const name = 'dir'; + await expect(checkAppExistsTask(dir, name)).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('checkPathExistsTask', () => { + it('should create a directory at the given path', async () => { + const appDir = 'projects/newProject'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should do nothing if a directory of the same name exists', async () => { + const appDir = 'projects/dir'; + await expect(checkPathExistsTask(appDir)).resolves.not.toThrow(); + expect(fs.existsSync(appDir)).toBe(true); + }); + + it('should fail if a file of the same name exists', async () => { + await expect(checkPathExistsTask('projects/my-module.ts')).rejects.toThrow( + 'already exists', + ); + }); +}); + +describe('createTemporaryAppFolderTask', () => { + it('should create a directory at a given path', async () => { + const tempDir = 'projects/tmpFolder'; + await expect(createTemporaryAppFolderTask(tempDir)).resolves.not.toThrow(); + expect(fs.existsSync(tempDir)).toBe(true); + }); + + it('should fail if a directory of the same name exists', async () => { + const tempDir = 'projects/dir'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); + + it('should fail if a file of the same name exists', async () => { + const tempDir = 'projects/dir/my-file.txt'; + await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow( + 'file already exists', + ); + }); +}); + +describe('buildAppTask', () => { + it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => { + const mockChdir = jest.spyOn(process, 'chdir'); + const mockExec = child_process.exec as unknown as jest.MockedFunction< + ( + command: string, + callback: (error: null, stdout: string, stderr: string) => void, + ) => void + >; + + // requires callback implementation to support `promisify` wrapper + // https://stackoverflow.com/a/60579617/10044859 + mockExec.mockImplementation((_command, callback) => { + callback(null, 'standard out', 'standard error'); + }); + + const appDir = 'projects/dir'; + await expect(buildAppTask(appDir)).resolves.not.toThrow(); + expect(mockChdir).toBeCalledTimes(2); + expect(mockChdir).toHaveBeenNthCalledWith(1, appDir); + expect(mockChdir).toHaveBeenNthCalledWith(2, appDir); + expect(mockExec).toBeCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + 'yarn install', + expect.any(Function), + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + 'yarn tsc', + expect.any(Function), + ); + }); + + it('should fail if project directory does not exist', async () => { + const appDir = 'projects/missingProject'; + await expect(buildAppTask(appDir)).rejects.toThrow( + 'no such file or directory', + ); + }); +}); + +describe('moveAppTask', () => { + const tempDir = 'tmp/mockApp/'; + const id = 'myApp'; + + it('should move all files in the temp dir to the target dir', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('projects/mockApp/.gitignore')).toBe(true); + expect(fs.existsSync('projects/mockApp/package.json')).toBe(true); + expect(fs.existsSync('projects/mockApp/packages/app/package.json')).toBe( + true, + ); + }); + + it('should fail to move files if destination already exists', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow( + 'dest already exists', + ); + }); + + it('should remove temporary files if move succeeded', async () => { + const destination = 'projects/mockApp'; + await moveAppTask(tempDir, destination, id); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); + + it('should remove temporary files if move failed', async () => { + const destination = 'projects'; + await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow(); + expect(fs.existsSync('tmp/mockApp')).toBe(false); + }); +}); + +describe('templatingTask', () => { + it('should generate a project populating context parameters', async () => { + const templateDir = 'templates/default-app'; + const destinationDir = 'templatedApp'; + const context = { + name: 'SuperCoolBackstageInstance', + dbTypeSqlite: true, + }; + await templatingTask(templateDir, destinationDir, context, '1.0.0'); + expect(fs.existsSync('templatedApp/package.json')).toBe(true); + expect(fs.existsSync('templatedApp/.dockerignore')).toBe(true); + await expect(fs.readJson('templatedApp/backstage.json')).resolves.toEqual({ + version: '1.0.0', + }); + // catalog was populated with `context.name` + expect( + fs.readFileSync('templatedApp/catalog-info.yaml', 'utf-8'), + ).toContain('name: SuperCoolBackstageInstance'); + // backend dependencies include `sqlite3` from `context.SQLite` + expect( + fs.readFileSync('templatedApp/packages/backend/package.json', 'utf-8'), + ).toContain('"sqlite3"'); + }); +}); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 19f0ed66b8..2793088892 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -14,15 +14,25 @@ * limitations under the License. */ +import { BACKSTAGE_JSON } from '@backstage/cli-common'; import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; -import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { + basename, + dirname, + join, + resolve as resolvePath, + relative as relativePath, +} from 'path'; +import { exec as execCb } from 'child_process'; import { packageVersions } from './versions'; +import { promisify } from 'util'; const TASK_NAME_MAX_LENGTH = 14; +const exec = promisify(execCb); export class Task { static log(name: string = '') { @@ -65,17 +75,28 @@ export class Task { } } +/** + * Generate a templated backstage project + * + * @param templateDir - location containing template files + * @param destinationDir - location to save templated project + * @param context - template parameters + */ export async function templatingTask( templateDir: string, destinationDir: string, context: any, + version: string, ) { const files = await recursive(templateDir).catch(error => { throw new Error(`Failed to read template directory: ${error.message}`); }); for (const file of files) { - const destinationFile = file.replace(templateDir, destinationDir); + const destinationFile = resolvePath( + destinationDir, + relativePath(templateDir, file), + ); await fs.ensureDir(dirname(destinationFile)); if (file.endsWith('.hbs')) { @@ -115,4 +136,112 @@ export async function templatingTask( }); } } + await Task.forItem('creating', BACKSTAGE_JSON, () => + fs.writeFile( + join(destinationDir, BACKSTAGE_JSON), + `{\n "version": ${JSON.stringify(version)}\n}\n`, + ), + ); +} + +/** + * Verify that application target does not already exist + * + * @param rootDir - The directory to create application folder `name` + * @param name - The specified name of the application + * @Throws Error - If directory with name of `destination` already exists + */ +export async function checkAppExistsTask(rootDir: string, name: string) { + await Task.forItem('checking', name, async () => { + const destination = resolvePath(rootDir, name); + + if (await fs.pathExists(destination)) { + const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); + throw new Error( + `A directory with the same name already exists: ${existing}\nPlease try again with a different app name`, + ); + } + }); +} + +/** + * Verify that application `path` exists, otherwise create the directory + * + * @param {string} path - target to create directory + * @throws {Error} if `path` is a file, or `fs.mkdir` fails + */ +export async function checkPathExistsTask(path: string) { + await Task.forItem('checking', path, async () => { + try { + await fs.mkdirs(path); + } catch (error) { + // will fail if a file already exists at given `path` + throw new Error(`Failed to create app directory: ${error.message}`); + } + }); +} + +/** + * Create a folder to store templated files + * + * @param {string} tempDir - target temporary directory + * @throws {Error} if `fs.mkdir` fails + */ +export async function createTemporaryAppFolderTask(tempDir: string) { + await Task.forItem('creating', 'temporary directory', async () => { + try { + await fs.mkdir(tempDir); + } catch (error) { + throw new Error(`Failed to create temporary app directory, ${error}`); + } + }); +} + +/** + * Run `yarn install` and `run tsc` in application directory + * + * @param {string} appDir - location of application to build + */ +export async function buildAppTask(appDir: string) { + const runCmd = async (cmd: string) => { + await Task.forItem('executing', cmd, async () => { + process.chdir(appDir); + await exec(cmd).catch(error => { + process.stdout.write(error.stderr); + process.stdout.write(error.stdout); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + }); + }; + + await runCmd('yarn install'); + await runCmd('yarn tsc'); +} + +/** + * Move temporary directory to destination application folder + * + * @param {string} tempDir source path to copy files from + * @param {string} destination target path to copy files + * @param {string} id + * @throws {Error} if `fs.move` fails + */ +export async function moveAppTask( + tempDir: string, + destination: string, + id: string, +) { + await Task.forItem('moving', id, async () => { + await fs + .move(tempDir, destination) + .catch(error => { + throw new Error( + `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, + ); + }) + .finally(() => { + // remove temporary files on both success and failure + fs.removeSync(tempDir); + }); + }); } diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 0846ee8e4f..974329e8d4 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -45,10 +45,12 @@ "@testing-library/user-event": "^13.1.8", "@types/react": "*", "react": "^16.12.0", + "react-use": "^17.2.4", "react-dom": "^16.12.0", "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", - "react-router-dom": "6.0.0-beta.0" + "react-router-dom": "6.0.0-beta.0", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.8.0", diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx new file mode 100644 index 0000000000..3907dc1007 --- /dev/null +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp } from '@backstage/test-utils'; +import { BackstageTheme } from '@backstage/theme'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import ObservableImpl from 'zen-observable'; +import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; + +describe('SidebarThemeSwitcher', () => { + let appThemeApi: jest.Mocked; + let apiRegistry: ApiRegistry; + + beforeEach(() => { + appThemeApi = { + activeThemeId$: jest.fn(), + getActiveThemeId: jest.fn(), + getInstalledThemes: jest.fn(), + setActiveThemeId: jest.fn(), + }; + + appThemeApi.activeThemeId$.mockReturnValue( + ObservableImpl.of('dark'), + ); + appThemeApi.getInstalledThemes.mockReturnValue([ + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: {} as unknown as BackstageTheme, + }, + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: {} as unknown as BackstageTheme, + }, + ]); + + apiRegistry = ApiRegistry.with(appThemeApiRef, appThemeApi); + }); + + it('should display current theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByText('Dark Theme')).toBeInTheDocument(); + expect( + getByText('Dark Theme').parentElement?.parentElement, + ).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select different theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + + userEvent.click(getByText('Light Theme')); + + expect(appThemeApi.setActiveThemeId).toHaveBeenCalledWith('light'); + }); +}); diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx new file mode 100644 index 0000000000..e13554bbf7 --- /dev/null +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx @@ -0,0 +1,125 @@ +/* + * 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 { SidebarItem } from '@backstage/core-components'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; +import { ListItemIcon, ListItemText, Menu, MenuItem } from '@material-ui/core'; +import AutoIcon from '@material-ui/icons/BrightnessAuto'; +import React, { cloneElement, useCallback, useState } from 'react'; +import { useObservable } from 'react-use'; + +type ThemeIconProps = { + active?: boolean; + icon: JSX.Element | undefined; +}; + +const ThemeIcon = ({ active, icon }: ThemeIconProps) => + icon ? ( + cloneElement(icon, { + color: active ? 'primary' : undefined, + }) + ) : ( + + ); + +export const SidebarThemeSwitcher = () => { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + const themeIds = appThemeApi.getInstalledThemes(); + const activeTheme = themeIds.find(t => t.id === themeId); + + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleSelectTheme = (newThemeId: string | undefined) => { + if (themeIds.some(t => t.id === newThemeId)) { + appThemeApi.setActiveThemeId(newThemeId); + } else { + appThemeApi.setActiveThemeId(undefined); + } + + setAnchorEl(undefined); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const ActiveIcon = useCallback( + () => , + [activeTheme], + ); + + return ( + <> + + + + Choose a theme + handleSelectTheme(undefined)} + > + + + + Auto + + + {themeIds.map(theme => { + const active = theme.id === themeId; + return ( + handleSelectTheme(theme.id)} + > + + + + {theme.title} + + ); + })} + + + ); +}; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index d4b1c8db49..adea9e14f2 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,6 +14,30 @@ * limitations under the License. */ +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { + AlertDisplay, + OAuthRequestDialog, + Sidebar, + SidebarDivider, + SidebarItem, + SidebarPage, + SidebarSpace, + SidebarSpacer, +} from '@backstage/core-components'; +import { + AnyApiFactory, + ApiFactory, + AppTheme, + attachComponentData, + BackstagePlugin, + configApiRef, + createApiFactory, + createRouteRef, + IconComponent, + RouteRef, +} from '@backstage/core-plugin-api'; import { ScmIntegrationsApi, scmIntegrationsApiRef, @@ -24,31 +48,7 @@ import React, { ComponentType, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import { hot } from 'react-hot-loader'; import { Route } from 'react-router'; - -import { - AlertDisplay, - OAuthRequestDialog, - Sidebar, - SidebarItem, - SidebarPage, - SidebarSpacer, -} from '@backstage/core-components'; - -import { - AnyApiFactory, - ApiFactory, - AppTheme, - attachComponentData, - configApiRef, - createApiFactory, - createRouteRef, - IconComponent, - RouteRef, - BackstagePlugin, -} from '@backstage/core-plugin-api'; - -import { createApp } from '@backstage/app-defaults'; -import { FlatRoutes } from '@backstage/core-app-api'; +import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; const GatheringRoute: (props: { path: string; @@ -203,6 +203,9 @@ export class DevAppBuilder { {this.sidebarItems} + + + {this.routes} diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 1b8e321edb..6c941cda58 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -25,7 +25,7 @@ }, "devDependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/errors": "^0.1.2", + "@backstage/errors": "^0.1.4", "@types/fs-extra": "^9.0.1", "@types/node": "^14.14.32", "@types/puppeteer": "^5.4.4", diff --git a/packages/embedded-techdocs-app/.eslintrc.js b/packages/embedded-techdocs-app/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/embedded-techdocs-app/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml new file mode 100644 index 0000000000..2d1bad2808 --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -0,0 +1,12 @@ +# NOTE: This file is used for testing techdocs-cli locally + +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7000 + +techdocs: + builder: 'external' + requestUrl: http://localhost:7000/api diff --git a/packages/embedded-techdocs-app/app-config.yaml b/packages/embedded-techdocs-app/app-config.yaml new file mode 100644 index 0000000000..56e63ff97c --- /dev/null +++ b/packages/embedded-techdocs-app/app-config.yaml @@ -0,0 +1,10 @@ +app: + title: Techdocs Preview App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:3000 + +techdocs: + builder: 'external' + requestUrl: http://localhost:3000/api diff --git a/packages/embedded-techdocs-app/cypress.json b/packages/embedded-techdocs-app/cypress.json new file mode 100644 index 0000000000..5de7ebffea --- /dev/null +++ b/packages/embedded-techdocs-app/cypress.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:3001", + "fixturesFolder": false, + "pluginsFile": false +} diff --git a/packages/embedded-techdocs-app/cypress/.eslintrc.json b/packages/embedded-techdocs-app/cypress/.eslintrc.json new file mode 100644 index 0000000000..2b3a458b95 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "plugins": ["cypress"], + "extends": ["plugin:cypress/recommended"], + "rules": { + "jest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "cy.contains"] + } + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true, + "optionalDependencies": true, + "peerDependencies": true, + "bundledDependencies": true + } + ] + } +} diff --git a/packages/embedded-techdocs-app/cypress/integration/app.js b/packages/embedded-techdocs-app/cypress/integration/app.js new file mode 100644 index 0000000000..d31f7a7964 --- /dev/null +++ b/packages/embedded-techdocs-app/cypress/integration/app.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +describe('App', () => { + it('should render the catalog', () => { + cy.visit('/'); + cy.contains('My Company Service Catalog'); + }); +}); diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json new file mode 100644 index 0000000000..412fd80357 --- /dev/null +++ b/packages/embedded-techdocs-app/package.json @@ -0,0 +1,66 @@ +{ + "name": "embedded-techdocs-app", + "version": "0.0.0", + "private": true, + "bundled": true, + "dependencies": { + "@backstage/app-defaults": "^0.1.0", + "@backstage/catalog-model": "^0.9.5", + "@backstage/cli": "^0.8.0", + "@backstage/config": "^0.1.10", + "@backstage/core-app-api": "^0.1.18", + "@backstage/core-components": "^0.7.1", + "@backstage/core-plugin-api": "^0.1.11", + "@backstage/integration-react": "^0.1.12", + "@backstage/plugin-catalog": "^0.7.2", + "@backstage/plugin-techdocs": "^0.12.3", + "@backstage/test-utils": "^0.1.19", + "@backstage/theme": "^0.2.11", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "history": "^5.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.8.2", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dom": "*", + "cross-env": "^7.0.0", + "cypress": "^7.3.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli app:serve --config ./app-config.yaml --config ./app-config.dev.yaml", + "build": "backstage-cli app:build --config ./app-config.yaml", + "build:dev": "backstage-cli app:build --config ./app-config.dev.yaml", + "clean": "backstage-cli clean", + "test": "backstage-cli test", + "lint": "backstage-cli lint", + "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", + "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "prettier": "@spotify/prettier-config", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/packages/embedded-techdocs-app/public/android-chrome-192x192.png b/packages/embedded-techdocs-app/public/android-chrome-192x192.png new file mode 100644 index 0000000000..eec0ae25b9 Binary files /dev/null and b/packages/embedded-techdocs-app/public/android-chrome-192x192.png differ diff --git a/packages/embedded-techdocs-app/public/apple-touch-icon.png b/packages/embedded-techdocs-app/public/apple-touch-icon.png new file mode 100644 index 0000000000..3158830ac7 Binary files /dev/null and b/packages/embedded-techdocs-app/public/apple-touch-icon.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-16x16.png b/packages/embedded-techdocs-app/public/favicon-16x16.png new file mode 100644 index 0000000000..58cf61a35e Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-16x16.png differ diff --git a/packages/embedded-techdocs-app/public/favicon-32x32.png b/packages/embedded-techdocs-app/public/favicon-32x32.png new file mode 100644 index 0000000000..c0915ece75 Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon-32x32.png differ diff --git a/packages/embedded-techdocs-app/public/favicon.ico b/packages/embedded-techdocs-app/public/favicon.ico new file mode 100644 index 0000000000..5e45e5dfbd Binary files /dev/null and b/packages/embedded-techdocs-app/public/favicon.ico differ diff --git a/packages/embedded-techdocs-app/public/index.html b/packages/embedded-techdocs-app/public/index.html new file mode 100644 index 0000000000..a6102f010d --- /dev/null +++ b/packages/embedded-techdocs-app/public/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + <%= app.title %> + + + +
+ + + diff --git a/packages/embedded-techdocs-app/public/manifest.json b/packages/embedded-techdocs-app/public/manifest.json new file mode 100644 index 0000000000..4a7c1b4ec4 --- /dev/null +++ b/packages/embedded-techdocs-app/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Backstage", + "name": "Backstage", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/embedded-techdocs-app/public/robots.txt b/packages/embedded-techdocs-app/public/robots.txt new file mode 100644 index 0000000000..01b0f9a107 --- /dev/null +++ b/packages/embedded-techdocs-app/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/embedded-techdocs-app/public/safari-pinned-tab.svg b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg new file mode 100644 index 0000000000..0f500b3002 --- /dev/null +++ b/packages/embedded-techdocs-app/public/safari-pinned-tab.svg @@ -0,0 +1 @@ +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/embedded-techdocs-app/src/App.test.tsx b/packages/embedded-techdocs-app/src/App.test.tsx new file mode 100644 index 0000000000..a5a4374976 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 React from 'react'; +import { renderWithEffects } from '@backstage/test-utils'; +import App from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, + techdocs: { + storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = await renderWithEffects(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/packages/embedded-techdocs-app/src/App.tsx b/packages/embedded-techdocs-app/src/App.tsx new file mode 100644 index 0000000000..00b232d8e1 --- /dev/null +++ b/packages/embedded-techdocs-app/src/App.tsx @@ -0,0 +1,69 @@ +/* + * 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 React from 'react'; +import { Navigate, Route } from 'react-router'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + +import { + DefaultTechDocsHome, + TechDocsIndexPage, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { apis } from './apis'; +import { Root } from './components/Root'; +import { techDocsPage } from './components/TechDocsPage'; +import * as plugins from './plugins'; + +const app = createApp({ + apis, + plugins: Object.values(plugins), +}); + +const AppProvider = app.getProvider(); +const AppRouter = app.getRouter(); + +const routes = ( + + + {/* we need this route as TechDocs header links relies on it */} + } + /> + }> + + + } + > + {techDocsPage} + + +); + +const App = () => ( + + + {routes} + + +); + +export default App; diff --git a/packages/embedded-techdocs-app/src/apis.ts b/packages/embedded-techdocs-app/src/apis.ts new file mode 100644 index 0000000000..6234e3b719 --- /dev/null +++ b/packages/embedded-techdocs-app/src/apis.ts @@ -0,0 +1,199 @@ +/* + * 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 { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + scmIntegrationsApiRef, + ScmIntegrationsApi, +} from '@backstage/integration-react'; +import { + AnyApiFactory, + configApiRef, + createApiFactory, + DiscoveryApi, + discoveryApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + SyncResult, + TechDocsApi, + techdocsApiRef, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs'; + +// TODO: Export type from plugin-techdocs and import this here +// import { ParsedEntityId } from '@backstage/plugin-techdocs' + +/** + * Note: Override TechDocs API to use local mkdocs server instead of techdocs-backend. + */ + +class TechDocsDevStorageApi implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + + async getEntityDocs(_entityId: EntityName, path: string) { + const apiOrigin = await this.getApiOrigin(); + // Irrespective of the entity, use mkdocs server to find the file for the path. + const url = `${apiOrigin}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + async syncEntityDocs(_: EntityName): Promise { + // this is just stub of this function as we don't need to check if docs are up to date, + // we always want to retrigger a new build + return 'cached'; + } + + // Used by transformer to modify the request to assets (CSS, Image) from inside the HTML. + async getBaseUrl( + oldBaseUrl: string, + _entityId: EntityName, + path: string, + ): Promise { + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${path}`).toString(); + } +} + +class TechDocsDevApi implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getEntityMetadata(_entityId: any) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'local', + }, + spec: { + owner: 'test', + lifecycle: 'experimental', + }, + }; + } + + async getTechDocsMetadata(_entityId: EntityName) { + return { + site_name: 'Live preview environment', + site_description: '', + }; + } +} + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevStorageApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: techdocsApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => + new TechDocsDevApi({ + configApi, + discoveryApi, + identityApi, + }), + }), + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +]; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx new file mode 100644 index 0000000000..c7b1c846c4 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * 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 React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx new file mode 100644 index 0000000000..073cf6edad --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * 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 React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/embedded-techdocs-app/src/components/Root/Root.tsx b/packages/embedded-techdocs-app/src/components/Root/Root.tsx new file mode 100644 index 0000000000..65e75a488a --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/Root.tsx @@ -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 React, { useContext, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; +import { + Sidebar, + SidebarPage, + sidebarConfig, + SidebarContext, + SidebarItem, + SidebarDivider, +} from '@backstage/core-components'; +import { NavLink } from 'react-router-dom'; + +const useSidebarLogoStyles = makeStyles({ + root: { + width: sidebarConfig.drawerWidthClosed, + height: 3 * sidebarConfig.logoHeight, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: -14, + }, + link: { + width: sidebarConfig.drawerWidthClosed, + marginLeft: 24, + }, +}); + +const SidebarLogo = () => { + const classes = useSidebarLogoStyles(); + const { isOpen } = useContext(SidebarContext); + + return ( +
+ + {isOpen ? : } + +
+ ); +}; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + {/* Global nav, not org-specific */} + + {/* End global nav */} + + {children} + +); diff --git a/packages/embedded-techdocs-app/src/components/Root/index.ts b/packages/embedded-techdocs-app/src/components/Root/index.ts new file mode 100644 index 0000000000..dff706f08f --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/Root/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { Root } from './Root'; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx new file mode 100644 index 0000000000..37e5f233d7 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/TechDocsPage.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Content } from '@backstage/core-components'; + +import { + Reader, + TechDocsPage, + TechDocsPageHeader, +} from '@backstage/plugin-techdocs'; + +const DefaultTechDocsPage = () => { + const techDocsMetadata = { + site_name: 'Live preview environment', + site_description: '', + }; + + return ( + + {({ entityRef, onReady }) => ( + <> + + + + + + )} + + ); +}; + +export const techDocsPage = ; diff --git a/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts new file mode 100644 index 0000000000..421e244613 --- /dev/null +++ b/packages/embedded-techdocs-app/src/components/TechDocsPage/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export * from './TechDocsPage'; diff --git a/packages/embedded-techdocs-app/src/index.tsx b/packages/embedded-techdocs-app/src/index.tsx new file mode 100644 index 0000000000..b15bc4c102 --- /dev/null +++ b/packages/embedded-techdocs-app/src/index.tsx @@ -0,0 +1,22 @@ +/* + * 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 '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/embedded-techdocs-app/src/plugins.ts b/packages/embedded-techdocs-app/src/plugins.ts new file mode 100644 index 0000000000..42fc16b339 --- /dev/null +++ b/packages/embedded-techdocs-app/src/plugins.ts @@ -0,0 +1,17 @@ +/* + * 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 { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; diff --git a/packages/embedded-techdocs-app/src/setupTests.ts b/packages/embedded-techdocs-app/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/embedded-techdocs-app/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 '@testing-library/jest-dom'; diff --git a/packages/techdocs-cli/.eslintrc.js b/packages/techdocs-cli/.eslintrc.js new file mode 100644 index 0000000000..884f559c0f --- /dev/null +++ b/packages/techdocs-cli/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + 'no-restricted-imports': 0, + }, + }, + ], +}; diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md new file mode 100644 index 0000000000..0f7c6be52f --- /dev/null +++ b/packages/techdocs-cli/CHANGELOG.md @@ -0,0 +1,96 @@ +# @techdocs/cli + +## 0.8.5 + +### Patch Changes + +- Reunified the [techdocs-cli](https://github.com/backstage/techdocs-cli) monorepo code back into the main [backstage](https://github.com/backstage/backstage) repo + + See [7288](https://github.com/backstage/backstage/issues/7288)). The changes include some internal refactoring that do not affect functionality beyond the local development setup. + +## 0.8.4 + +### Patch Changes + +- 8333394: The [change](https://github.com/backstage/techdocs-cli/commit/b25014cec313d46ce1c9b4f324cc09047a00fc1f) updated the `@backstage/techdocs-common` from version `0.9.0` to `0.10.2` and one of the intermediate versions, the [0.10.0](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#patch-changes-2), introduced the use of search in context that requires an implementation for the Search API. + + Created a custom techdocs page to disable search in the Reader component, preventing it from using the Search API, as we don't want to provide search in preview mode. + +## 0.8.3 + +### Patch Changes + +- edbb988: Upgrades the techdocs common page to the latest version 0.10.2. + + See [@backstage/techdocs-common changelog](https://github.com/backstage/backstage/blob/cac4afb95fdbd130a66e53a1b0430a1e62787a7f/packages/techdocs-common/CHANGELOG.md#L3). + +- db4ebfc: Add an `etag` flag to the `generate` command that is stored in the `techdocs_metadata.json` file. + +## 0.8.2 + +### Patch Changes + +- 8fc7384: Allow to execute techdocs-cli serve using docker techdocs-container on Windows + +## 0.8.1 + +### Patch Changes + +- 0187424: Separate build and publish release steps + +## 0.8.0 + +### Minor Changes + +- c6f437a: OpenStack Swift configuration changed due to OSS SDK Client change in @backstage/techdocs-common, it was a breaking change. + PR Reference: https://github.com/backstage/backstage/pull/6839 + +### Patch Changes + +- 05f0409: Merge Jobs for Release Pull Requests and Package Publishes + +## 0.7.0 + +### Minor Changes + +- 9d1f8d8: The `techdocs-cli publish` command will now publish TechDocs content to remote + storage using the lowercase'd entity triplet as the storage path. This is in + line with the beta release of the TechDocs plugin (`v0.11.0`). + + If you have been running `techdocs-cli` prior to this version, you will need to + follow this [migration guide](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). + +## 0.6.2 + +### Patch Changes + +- f1bcf1a: Changelog (from v0.6.1 to v0.6.2) + + #### :bug: Bug Fix + + - `techdocs-cli` + - [#105](https://github.com/backstage/techdocs-cli/pull/105) Add azureAccountKey parameter back to the publish command ([@emmaindal](https://github.com/emmaindal)) + + #### :house: Internal + + - `embedded-techdocs-app` + - [#122](https://github.com/backstage/techdocs-cli/pull/122) chore(deps-dev): bump @types/node from 12.20.20 to 16.7.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#120](https://github.com/backstage/techdocs-cli/pull/120) chore(deps-dev): bump @types/react-dom from 16.9.14 to 17.0.9 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#119](https://github.com/backstage/techdocs-cli/pull/119) chore(deps-dev): bump @testing-library/user-event from 12.8.3 to 13.2.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#118](https://github.com/backstage/techdocs-cli/pull/118) chore(deps-dev): bump @testing-library/react from 10.4.9 to 12.0.0 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - Other + - [#117](https://github.com/backstage/techdocs-cli/pull/117) chore(deps): bump @backstage/plugin-catalog from 0.6.11 to 0.6.12 ([@dependabot[bot]](https://github.com/apps/dependabot)) + - [#124](https://github.com/backstage/techdocs-cli/pull/124) Update release process docs ([@emmaindal](https://github.com/emmaindal)) + - [#116](https://github.com/backstage/techdocs-cli/pull/116) ignore dependabot branches for project board workflow ([@emmaindal](https://github.com/emmaindal)) + - [#106](https://github.com/backstage/techdocs-cli/pull/106) Configure dependabot for all packages ([@emmaindal](https://github.com/emmaindal)) + - [#102](https://github.com/backstage/techdocs-cli/pull/102) readme: add information about running techdocs-common locally ([@vcapretz](https://github.com/vcapretz)) + - [#103](https://github.com/backstage/techdocs-cli/pull/103) Introduce changesets and improve the publish workflow ([@minkimcello](https://github.com/minkimcello)) + - [#101](https://github.com/backstage/techdocs-cli/pull/101) update yarn lockfile to get rid of old version of node-forge ([@emmaindal](https://github.com/emmaindal)) + + #### Committers: 3 + + Thank you for contributing ❤️ + + - `Emma Indal` ([@emmaindal](https://github.com/emmaindal)) + - `Min Kim` ([@minkimcello](https://github.com/minkimcello)) + - `Vitor Capretz` ([@vcapretz](https://github.com/vcapretz)) diff --git a/packages/techdocs-cli/README.md b/packages/techdocs-cli/README.md new file mode 100644 index 0000000000..6f070147eb --- /dev/null +++ b/packages/techdocs-cli/README.md @@ -0,0 +1,68 @@ +# techdocs-cli + +[![NPM Version badge](https://img.shields.io/npm/v/@techdocs/cli)](https://www.npmjs.com/package/@techdocs/cli) + +## Usage + +See [techdocs-cli usage docs](https://backstage.io/docs/features/techdocs/cli). + +## Development + +NOTE: When we build `techdocs-cli` it copies the output `embedded-techdocs-app` +bundle into the `packages/techdocs-cli/dist` which is then published with the +`@techdocs/cli` npm package. + +### Running + +```sh +# From the root of this repository run +# NOTE: This will build the embedded-techdocs-app and copy the output into the cli dist directory +yarn build --scope @techdocs/cli + +# Now execute the binary +packages/techdocs-cli/bin/techdocs-cli + +# ... or as a shell alias in ~/.zshrc or ~/.zprofile or ~/.bashrc or similar +export PATH=/path/to/backstage/packages/techdocs-cli/bin:$PATH +``` + +If you want to test live test changes to the `packages/embedded-techdocs-app` +you can serve the app and run the CLI using the following commands: + +```sh +# Open a shell to the embedded-techdocs-app directory +cd packages/embedded-techdocs-app + +# Run the embedded-techdocs-app using dev mode +yarn start + +# In another shell use the techdocs-cli from the root of this repo +yarn techdocs-cli:dev [...options] +``` + +### Testing + +Running unit tests requires mkdocs to be installed locally: + +```sh +pip install mkdocs +pip install mkdocs-techdocs-core +``` + +Then run `yarn test`. + +### Use an example docs project + +We have created an [example documentation project](https://github.com/backstage/techdocs-container/tree/main/mock-docs) and it's shipped with [techdocs-container](https://github.com/backstage/techdocs-container) repository, for the purpose of local development. But you are free to create your own local test site. All it takes is a `docs/index.md` and `mkdocs.yml` in a directory. + +```sh +git clone https://github.com/backstage/techdocs-container.git + +cd techdocs-container/mock-docs + +# To get a view of your docs in Backstage, use: +techdocs-cli serve + +# To view the raw mkdocs site (without Backstage), use: +techdocs-cli serve:mkdocs +``` diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli new file mode 100755 index 0000000000..6c0bbd37b5 --- /dev/null +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* + * 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 path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal) { + require('..'); +} else { + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json new file mode 100644 index 0000000000..a24a1be0f8 --- /dev/null +++ b/packages/techdocs-cli/package.json @@ -0,0 +1,72 @@ +{ + "name": "@techdocs/cli", + "description": "Utility CLI for managing TechDocs sites in Backstage.", + "version": "0.8.5", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/techdocs-cli" + }, + "keywords": [ + "backstage", + "techdocs" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "types": "", + "scripts": { + "start": "nodemon --", + "build": "./scripts/build.sh", + "clean": "backstage-cli clean", + "lint": "backstage-cli lint", + "test": "backstage-cli test --testPathIgnorePatterns=src/e2e.test.ts", + "test:e2e": "backstage-cli test src/e2e.test.ts", + "test:e2e:ci": "backstage-cli test --watchAll=false --ci src/e2e.test.ts" + }, + "bin": { + "techdocs-cli": "bin/techdocs-cli" + }, + "devDependencies": { + "@backstage/cli": "^0.8.0", + "@types/commander": "^2.12.2", + "@types/fs-extra": "^9.0.6", + "@types/http-proxy": "^1.17.4", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react-dev-utils": "^9.0.4", + "@types/serve-handler": "^6.1.0", + "@types/webpack-env": "^1.15.3", + "embedded-techdocs-app": "0.0.0", + "find-process": "^1.4.5", + "nodemon": "^2.0.2", + "ts-node": "^10.0.0" + }, + "files": [ + "bin", + "dist" + ], + "nodemonConfig": { + "watch": "./src", + "exec": "bin/techdocs-cli", + "ext": "ts" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.7", + "@backstage/catalog-model": "^0.9.5", + "@backstage/config": "^0.1.10", + "@backstage/techdocs-common": "^0.10.4", + "@types/dockerode": "^3.2.1", + "commander": "^6.1.0", + "dockerode": "^3.2.1", + "fs-extra": "^9.0.1", + "http-proxy": "^1.18.1", + "react-dev-utils": "^11.0.4", + "serve-handler": "^6.1.3", + "winston": "^3.2.1" + } +} diff --git a/packages/techdocs-cli/scripts/build.sh b/packages/techdocs-cli/scripts/build.sh new file mode 100755 index 0000000000..630fef42f0 --- /dev/null +++ b/packages/techdocs-cli/scripts/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# 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. + +set -e + +SCRIPT_DIR=$(dirname $0) +TECHDOCS_CLI_DIR="$SCRIPT_DIR"/.. +TECHDOCS_CLI_EMBEDDED_APP_DIR="$TECHDOCS_CLI_DIR"/../embedded-techdocs-app + +compile_and_build_cli() { + echo "📄 Compiling..." + yarn workspace @techdocs/cli tsc > /dev/null + echo "📦️ Building..." + pushd $TECHDOCS_CLI_DIR > /dev/null + npx backstage-cli build --outputs cjs > /dev/null + popd > /dev/null +} + +build_and_embed_app() { + echo "🚚 Embedding app..." + if [ "$TECHDOCS_CLI_DEV_MODE" = "true" ] ; then + yarn workspace embedded-techdocs-app build:dev > /dev/null + else + yarn workspace embedded-techdocs-app build > /dev/null + fi + cp -r "$TECHDOCS_CLI_EMBEDDED_APP_DIR"/dist "$TECHDOCS_CLI_DIR"/dist/techdocs-preview-bundle > /dev/null +} + +compile_and_build_cli +build_and_embed_app +echo "🏁 Ready!" diff --git a/packages/techdocs-cli/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts new file mode 100644 index 0000000000..a255805139 --- /dev/null +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -0,0 +1,99 @@ +/* + * 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 { resolve } from 'path'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import Docker from 'dockerode'; +import { + TechdocsGenerator, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import { DockerContainerRunner } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { + convertTechDocsRefToLocationAnnotation, + createLogger, +} from '../../lib/utility'; +import { stdout } from 'process'; + +export default async function generate(cmd: Command) { + // Use techdocs-common package to generate docs. Keep consistency between Backstage and CI generating docs. + // Docs can be prepared using actions/checkout or git clone, or similar paradigms on CI. The TechDocs CI workflow + // will run on the CI pipeline containing the documentation files. + + const logger = createLogger({ verbose: cmd.verbose }); + + const sourceDir = resolve(cmd.sourceDir); + const outputDir = resolve(cmd.outputDir); + const dockerImage = cmd.dockerImage; + const pullImage = cmd.pull; + + logger.info(`Using source dir ${sourceDir}`); + logger.info(`Will output generated files in ${outputDir}`); + + logger.verbose('Creating output directory if it does not exist.'); + + await fs.ensureDir(outputDir); + + const config = new ConfigReader({ + techdocs: { + generator: { + runIn: cmd.docker ? 'docker' : 'local', + dockerImage, + pullImage, + }, + }, + }); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + let parsedLocationAnnotation = {} as ParsedLocationAnnotation; + if (cmd.techdocsRef) { + try { + parsedLocationAnnotation = convertTechDocsRefToLocationAnnotation( + cmd.techdocsRef, + ); + } catch (err) { + logger.error(err.message); + } + } + + // Generate docs using @backstage/techdocs-common + const techdocsGenerator = await TechdocsGenerator.fromConfig(config, { + logger, + containerRunner, + }); + + logger.info('Generating documentation...'); + + await techdocsGenerator.run({ + inputDir: sourceDir, + outputDir, + ...(cmd.techdocsRef + ? { + parsedLocationAnnotation, + } + : {}), + logger, + etag: cmd.etag, + ...(process.env.LOG_LEVEL === 'debug' ? { logStream: stdout } : {}), + }); + + logger.info('Done!'); +} diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts new file mode 100644 index 0000000000..d4f41cf7a3 --- /dev/null +++ b/packages/techdocs-cli/src/commands/index.ts @@ -0,0 +1,247 @@ +/* + * 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 { CommanderStatic } from 'commander'; +import { TechdocsGenerator } from '@backstage/techdocs-common'; + +const defaultDockerImage = TechdocsGenerator.defaultDockerImage; + +export function registerCommands(program: CommanderStatic) { + program + .command('generate') + .description('Generate TechDocs documentation site using MkDocs.') + .option( + '--source-dir ', + 'Source directory containing mkdocs.yml and docs/ directory.', + '.', + ) + .option( + '--output-dir ', + 'Output directory containing generated TechDocs site.', + './site/', + ) + .option( + '--docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option('--no-pull', 'Do not pull the latest docker image', false) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable and plugins in current user environment.', + ) + .option( + '--techdocs-ref ', + 'The repository hosting documentation source files e.g. github:https://ghe.mycompany.net.com/org/repo.' + + '\nThis value is same as the backstage.io/techdocs-ref annotation of the corresponding Backstage entity.' + + '\nIt is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml if not found.\n', + ) + .option( + '--etag ', + 'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .alias('build') + .action(lazy(() => import('./generate/generate').then(m => m.default))); + + program + .command('migrate') + .description( + 'Migrate objects with case-sensitive entity triplets to lower-case versions.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--awsBucketRootPath', + 'Optional sub-directory to store files in Amazon S3', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--removeOriginal', + 'Optional Files are copied by default. If flag is set, files are renamed/moved instead.', + false, + ) + .option( + '--concurrency ', + 'Optional Controls the number of API requests allowed to be performed simultaneously.', + '25', + ) + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./migrate/migrate').then(m => m.default))); + + program + .command('publish') + .description( + 'Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.', + ) + .requiredOption( + '--publisher-type ', + '(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml', + ) + .requiredOption( + '--storage-name ', + '(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName', + ) + .requiredOption( + '--entity ', + '(Required always) Entity uid separated by / in namespace/kind/name order (case-sensitive). Example: default/Component/myEntity ', + ) + .option( + '--legacyUseCaseSensitiveTripletPaths', + 'Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). Only use if your TechDocs backend is configured the same way.', + false, + ) + .option( + '--azureAccountName ', + '(Required for Azure) specify when --publisher-type azureBlobStorage', + ) + .option( + '--azureAccountKey ', + 'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.', + ) + .option( + '--awsRoleArn ', + 'Optional AWS ARN of role to be assumed.', + ) + .option( + '--awsEndpoint ', + 'Optional AWS endpoint to send requests to.', + ) + .option('--awsS3sse ', 'Optional AWS S3 Server Side Encryption.') + .option( + '--awsS3ForcePathStyle', + 'Optional AWS S3 option to force path style.', + ) + .option( + '--osCredentialId ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSecret ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osAuthUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--osSwiftUrl ', + '(Required for OpenStack) specify when --publisher-type openStackSwift', + ) + .option( + '--gcsBucketRootPath', + 'Optional sub-directory to store files in Google cloud storage', + ) + .option( + '--directory ', + 'Path of the directory containing generated files to publish', + './site/', + ) + .action(lazy(() => import('./publish/publish').then(m => m.default))); + + program + .command('serve:mkdocs') + .description('Serve a documentation project locally using MkDocs serve.') + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, run `mkdocs serve` in current user environment.', + ) + .option('-p, --port ', 'Port to serve documentation locally', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/mkdocs').then(m => m.default))); + + program + .command('serve') + .description( + 'Serve a documentation project locally in a Backstage app-like environment', + ) + .option( + '-i, --docker-image ', + 'The mkdocs docker container to use', + defaultDockerImage, + ) + .option( + '--no-docker', + 'Do not use Docker, use MkDocs executable in current user environment.', + ) + .option('--mkdocs-port ', 'Port for MkDocs server to use', '8000') + .option('-v --verbose', 'Enable verbose output.', false) + .action(lazy(() => import('./serve/serve').then(m => m.default))); +} + +// Wraps an action function so that it always exits and handles errors +// Humbly taken from backstage-cli's registerCommands +function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + process.exit(0); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error.message); + process.exit(1); + } + }; +} diff --git a/packages/techdocs-cli/src/commands/migrate/migrate.ts b/packages/techdocs-cli/src/commands/migrate/migrate.ts new file mode 100644 index 0000000000..1cd8fca65b --- /dev/null +++ b/packages/techdocs-cli/src/commands/migrate/migrate.ts @@ -0,0 +1,55 @@ +/* + * 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 { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function migrate(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + if (!publisher.migrateDocsCase) { + throw new Error(`Migration not implemented for ${cmd.publisherType}`); + } + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + throw new Error(''); + } + + // Validate and parse migration arguments. + const removeOriginal = cmd.removeOriginal; + const numericConcurrency = parseInt(cmd.concurrency, 10); + + if (!Number.isInteger(numericConcurrency) || numericConcurrency <= 0) { + throw new Error( + `Concurrency must be a number greater than 1. ${cmd.concurrency} provided.`, + ); + } + + await publisher.migrateDocsCase({ + concurrency: numericConcurrency, + removeOriginal, + }); +} diff --git a/packages/techdocs-cli/src/commands/publish/publish.ts b/packages/techdocs-cli/src/commands/publish/publish.ts new file mode 100644 index 0000000000..11a47d4a71 --- /dev/null +++ b/packages/techdocs-cli/src/commands/publish/publish.ts @@ -0,0 +1,52 @@ +/* + * 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 { resolve } from 'path'; +import { Command } from 'commander'; +import { createLogger } from '../../lib/utility'; +import { SingleHostDiscovery } from '@backstage/backend-common'; +import { Publisher } from '@backstage/techdocs-common'; +import { Entity } from '@backstage/catalog-model'; +import { PublisherConfig } from '../../lib/PublisherConfig'; + +export default async function publish(cmd: Command): Promise { + const logger = createLogger({ verbose: cmd.verbose }); + + const config = PublisherConfig.getValidConfig(cmd); + const discovery = SingleHostDiscovery.fromConfig(config); + const publisher = await Publisher.fromConfig(config, { logger, discovery }); + + // Check that the publisher's underlying storage is ready and available. + const { isAvailable } = await publisher.getReadiness(); + if (!isAvailable) { + // Error messages printed in getReadiness() call. This ensures exit code 1. + return Promise.reject(new Error('')); + } + + const [namespace, kind, name] = cmd.entity.split('/'); + const entity = { + kind, + metadata: { + namespace, + name, + }, + } as Entity; + + const directory = resolve(cmd.directory); + await publisher.publish({ entity, directory }); + + return true; +} diff --git a/packages/techdocs-cli/src/commands/serve/mkdocs.ts b/packages/techdocs-cli/src/commands/serve/mkdocs.ts new file mode 100644 index 0000000000..f6779c5146 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/mkdocs.ts @@ -0,0 +1,71 @@ +/* + * 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 { Command } from 'commander'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { createLogger } from '../../lib/utility'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; + +export default async function serveMkdocs(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + const dockerAddr = `http://0.0.0.0:${cmd.port}`; + const localAddr = `http://127.0.0.1:${cmd.port}`; + const expectedDevAddr = cmd.docker ? dockerAddr : localAddr; + // We want to open browser only once based on a log. + let boolOpenBrowserTriggered = false; + + const logFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line in between + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + // Logs from container is verbose. + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !boolOpenBrowserTriggered && + line.includes(`Serving on ${expectedDevAddr}`) + ) { + // Always open the local address, since 0.0.0.0 belongs to docker + logger.info(`\nStarting mkdocs server on ${localAddr}\n`); + openBrowser(localAddr); + boolOpenBrowserTriggered = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + + // Commander stores --no-docker in cmd.docker variable + const childProcess = await runMkdocsServer({ + port: cmd.port, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: logFunc, + stderrLogFunc: logFunc, + }); + + // Keep waiting for user to cancel the process + await waitForSignal([childProcess]); +} diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts new file mode 100644 index 0000000000..1182c850b3 --- /dev/null +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -0,0 +1,125 @@ +/* + * 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 { Command } from 'commander'; +import path from 'path'; +import openBrowser from 'react-dev-utils/openBrowser'; +import HTTPServer from '../../lib/httpServer'; +import { runMkdocsServer } from '../../lib/mkdocsServer'; +import { LogFunc, waitForSignal } from '../../lib/run'; +import { createLogger } from '../../lib/utility'; + +export default async function serve(cmd: Command) { + const logger = createLogger({ verbose: cmd.verbose }); + + // Determine if we want to run in local dev mode or not + // This will run the backstage http server on a different port and only used + // for proxying mkdocs to the backstage app running locally (e.g. with webpack-dev-server) + const isDevMode = Object.keys(process.env).includes('TECHDOCS_CLI_DEV_MODE') + ? true + : false; + + // TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle + // a backstage app, we define app.baseUrl in the app-config.yaml. + // Hence, it is complicated to make this configurable. + const backstagePort = 3000; + const backstageBackendPort = 7000; + + const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`; + const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`; + const mkdocsExpectedDevAddr = cmd.docker ? mkdocsDockerAddr : mkdocsLocalAddr; + + let mkdocsServerHasStarted = false; + const mkdocsLogFunc: LogFunc = data => { + // Sometimes the lines contain an unnecessary extra new line + const logLines = data.toString().split('\n'); + const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]'; + logLines.forEach(line => { + if (line === '') { + return; + } + + logger.verbose(`${logPrefix} ${line}`); + + // When the server has started, open a new browser tab for the user. + if ( + !mkdocsServerHasStarted && + line.includes(`Serving on ${mkdocsExpectedDevAddr}`) + ) { + mkdocsServerHasStarted = true; + } + }); + }; + // mkdocs writes all of its logs to stderr by default, and not stdout. + // https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006 + // Had me questioning this whole implementation for half an hour. + logger.info('Starting mkdocs server.'); + const mkdocsChildProcess = await runMkdocsServer({ + port: cmd.mkdocsPort, + dockerImage: cmd.dockerImage, + useDocker: cmd.docker, + stdoutLogFunc: mkdocsLogFunc, + stderrLogFunc: mkdocsLogFunc, + }); + + // Wait until mkdocs server has started so that Backstage starts with docs loaded + // Takes 1-5 seconds + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 3000)); + if (mkdocsServerHasStarted) { + break; + } + logger.info('Waiting for mkdocs server to start...'); + } + + if (!mkdocsServerHasStarted) { + logger.error( + 'mkdocs server did not start. Exiting. Try re-running command with -v option for more details.', + ); + } + + // Run the embedded-techdocs Backstage app + const techdocsPreviewBundlePath = path.join( + path.dirname(require.resolve('@techdocs/cli/package.json')), + 'dist', + 'techdocs-preview-bundle', + ); + + const port = isDevMode ? backstageBackendPort : backstagePort; + const httpServer = new HTTPServer( + techdocsPreviewBundlePath, + port, + cmd.mkdocsPort, + cmd.verbose, + ); + + httpServer + .serve() + .catch(err => { + logger.error(err); + mkdocsChildProcess.kill(); + process.exit(1); + }) + .then(() => { + // The last three things default/component/local/ don't matter. They can be anything. + openBrowser(`http://localhost:${port}/docs/default/component/local/`); + logger.info( + `Serving docs in Backstage at http://localhost:${port}/docs/default/component/local/\nOpening browser.`, + ); + }); + + await waitForSignal([mkdocsChildProcess]); +} diff --git a/packages/techdocs-cli/src/e2e.test.ts b/packages/techdocs-cli/src/e2e.test.ts new file mode 100644 index 0000000000..088dbc44b8 --- /dev/null +++ b/packages/techdocs-cli/src/e2e.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { execSync, spawn, SpawnOptionsWithoutStdio } from 'child_process'; +import path from 'path'; + +import findProcess from 'find-process'; + +const executeCommand = ( + command: string, + args: string[], + options?: SpawnOptionsWithoutStdio, +): Promise<{ + exit: number; + stdout: string; + stderr: string; +}> => { + return new Promise(resolve => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + const shell = process.platform === 'win32'; + const proc = spawn(command, args, { ...options, shell }); + + proc.stdout?.on('data', data => { + stdout.push(Buffer.from(data)); + }); + + proc.stderr?.on('data', data => { + stderr.push(Buffer.from(data)); + }); + + proc.on('exit', code => { + resolve({ + exit: code ?? 0, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +}; + +const timeout = 25000; + +jest.setTimeout(timeout * 2); + +describe('end-to-end', () => { + const cwd = path.resolve(__dirname, 'fixture'); + + afterEach(async () => { + // On Windows the pid of a spawned process may be wrong + // Because of this, we should be kill the MKDocs after the test + // (e.g. https://github.com/nodejs/node/issues/4289#issuecomment-854270414) + if (process.platform === 'win32') { + const procs = await findProcess('name', 'mkdocs', true); + procs.forEach((proc: { pid: number }) => { + process.kill(proc.pid); + }); + } + }); + + beforeAll(() => { + execSync('yarn workspace @techdocs/cli link', { stdio: 'ignore' }); + }); + + afterAll(() => { + execSync('yarn workspace @techdocs/cli unlink', { stdio: 'ignore' }); + }); + + it('shows help text', async () => { + const proc = await executeCommand('techdocs-cli', ['--help']); + expect(proc.stdout).toContain('Usage: techdocs-cli [options]'); + expect(proc.exit).toEqual(0); + }); + + it('can generate', async () => { + const proc = await executeCommand( + 'techdocs-cli', + ['generate', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Successfully generated docs'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in mkdocs', async () => { + const proc = await executeCommand( + 'techdocs-cli', + ['serve:mkdocs', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Starting mkdocs server'); + expect(proc.exit).toEqual(0); + }); + + it('can serve in backstage', async () => { + jest.setTimeout(30000); + const proc = await executeCommand( + 'techdocs-cli', + ['serve', '--no-docker'], + { cwd, timeout }, + ); + expect(proc.stdout).toContain('Starting mkdocs server'); + expect(proc.stdout).toContain('Serving docs in Backstage at'); + expect(proc.exit).toEqual(0); + }); +}); diff --git a/packages/techdocs-cli/src/fixture/docs/README.md b/packages/techdocs-cli/src/fixture/docs/README.md new file mode 100644 index 0000000000..c32f73f6f4 --- /dev/null +++ b/packages/techdocs-cli/src/fixture/docs/README.md @@ -0,0 +1 @@ +# Test Fixture diff --git a/packages/techdocs-cli/src/fixture/mkdocs.yml b/packages/techdocs-cli/src/fixture/mkdocs.yml new file mode 100644 index 0000000000..5d5c2a02ae --- /dev/null +++ b/packages/techdocs-cli/src/fixture/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: docs-test-fixture +site_description: Documentation site test fixture + +nav: + - HOME: README.md + +plugins: + - techdocs-core diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts new file mode 100644 index 0000000000..6f4437cbce --- /dev/null +++ b/packages/techdocs-cli/src/index.ts @@ -0,0 +1,29 @@ +/* + * 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 program from 'commander'; +import { registerCommands } from './commands'; +import { version } from '../package.json'; + +const main = (argv: string[]) => { + program.name('techdocs-cli').version(version); + + registerCommands(program); + + program.parse(argv); +}; + +main(process.argv); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts new file mode 100644 index 0000000000..88f542c049 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { Command } from 'commander'; +import { PublisherConfig } from './PublisherConfig'; + +describe('getValidPublisherConfig', () => { + it('should not allow unknown publisher types', () => { + const invalidConfig = { + publisherType: 'unknown publisher', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(invalidConfig)).toThrowError( + `Unknown publisher type ${invalidConfig.publisherType}`, + ); + }); + + describe('for azureBlobStorage', () => { + it('should require --azureAccountName', () => { + const config = { + publisherType: 'azureBlobStorage', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + 'azureBlobStorage requires --azureAccountName to be specified', + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'azureBlobStorage', + azureAccountName: 'someAccountName', + storageName: 'someContainer', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'azureBlobStorage', + ); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ), + ).toBe('someContainer'); + expect( + actualConfig.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ), + ).toBe('someAccountName'); + }); + }); + + describe('for awsS3', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + awsBucketRootPath: 'backstage-data/techdocs', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketName'), + ).toBe('someStorageName'); + expect( + actualConfig.getString('techdocs.publisher.awsS3.bucketRootPath'), + ).toBe('backstage-data/techdocs'); + }); + + it('should return valid ConfigReader with SSE option', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + awsS3sse: 'aws:kms', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect(actualConfig.getString('techdocs.publisher.awsS3.sse')).toBe( + 'aws:kms', + ); + }); + }); + + describe('for openStackSwift', () => { + it('should throw error on missing parameters', () => { + const config = { + publisherType: 'openStackSwift', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + } as unknown as Command; + + expect(() => PublisherConfig.getValidConfig(config)).toThrowError( + `openStackSwift requires the following params to be specified: ${[ + 'osAuthUrl', + 'osSwiftUrl', + ].join(', ')}`, + ); + }); + + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'openStackSwift', + storageName: 'someStorageName', + osCredentialId: 'someCredentialId', + osSecret: 'someSecret', + osAuthUrl: 'someAuthUrl', + osSwiftUrl: 'someSwiftUrl', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'openStackSwift', + ); + expect( + actualConfig.getConfig('techdocs.publisher.openStackSwift').get(), + ).toMatchObject({ + containerName: 'someStorageName', + credentials: { + id: 'someCredentialId', + secret: 'someSecret', + }, + authUrl: 'someAuthUrl', + swiftUrl: 'someSwiftUrl', + }); + }); + }); + + describe('for googleGcs', () => { + it('should return valid ConfigReader', () => { + const config = { + publisherType: 'googleGcs', + storageName: 'someStorageName', + gcsBucketRootPath: 'backstage-data/techdocs', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe( + 'googleGcs', + ); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketName'), + ).toBe('someStorageName'); + expect( + actualConfig.getString('techdocs.publisher.googleGcs.bucketRootPath'), + ).toBe('backstage-data/techdocs'); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.ts b/packages/techdocs-cli/src/lib/PublisherConfig.ts new file mode 100644 index 0000000000..d4779995c0 --- /dev/null +++ b/packages/techdocs-cli/src/lib/PublisherConfig.ts @@ -0,0 +1,166 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { Command } from 'commander'; + +type Publisher = keyof typeof PublisherConfig['configFactories']; +type PublisherConfiguration = { + [p in Publisher]?: any; +} & { + type: Publisher; +}; + +/** + * Helper when working with publisher-related configurations. + */ +export class PublisherConfig { + /** + * Maps publisher-specific config keys to config getters. + */ + private static configFactories = { + awsS3: PublisherConfig.getValidAwsS3Config, + azureBlobStorage: PublisherConfig.getValidAzureConfig, + googleGcs: PublisherConfig.getValidGoogleGcsConfig, + openStackSwift: PublisherConfig.getValidOpenStackSwiftConfig, + }; + + /** + * Returns Backstage config suitable for use when instantiating a Publisher. If + * there are any missing or invalid options provided, an error is thrown. + * + * Note: This assumes that proper credentials are set in Environment + * variables for the respective GCS/AWS clients to work. + */ + static getValidConfig(cmd: Command): ConfigReader { + const publisherType = cmd.publisherType; + + if (!PublisherConfig.isKnownPublisher(publisherType)) { + throw new Error(`Unknown publisher type ${cmd.publisherType}`); + } + + return new ConfigReader({ + // This backend config is not used at all. Just something needed a create a mock discovery instance. + backend: { + baseUrl: 'http://localhost:7000', + listen: { + port: 7000, + }, + }, + techdocs: { + publisher: PublisherConfig.configFactories[publisherType](cmd), + legacyUseCaseSensitiveTripletPaths: + cmd.legacyUseCaseSensitiveTripletPaths, + }, + }); + } + + /** + * Typeguard to ensure the publisher has a known config getter. + */ + private static isKnownPublisher( + type: string, + ): type is keyof typeof PublisherConfig['configFactories'] { + return PublisherConfig.configFactories.hasOwnProperty(type); + } + + /** + * Retrieve valid AWS S3 configuration based on the command. + */ + private static getValidAwsS3Config(cmd: Command): PublisherConfiguration { + return { + type: 'awsS3', + awsS3: { + bucketName: cmd.storageName, + ...(cmd.awsBucketRootPath && { bucketRootPath: cmd.awsBucketRootPath }), + ...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }), + ...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }), + ...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }), + ...(cmd.awsS3sse && { sse: cmd.awsS3sse }), + }, + }; + } + + /** + * Retrieve valid Azure Blob Storage configuration based on the command. + */ + private static getValidAzureConfig(cmd: Command): PublisherConfiguration { + if (!cmd.azureAccountName) { + throw new Error( + `azureBlobStorage requires --azureAccountName to be specified`, + ); + } + + return { + type: 'azureBlobStorage', + azureBlobStorage: { + containerName: cmd.storageName, + credentials: { + accountName: cmd.azureAccountName, + accountKey: cmd.azureAccountKey, + }, + }, + }; + } + + /** + * Retrieve valid GCS configuration based on the command. + */ + private static getValidGoogleGcsConfig(cmd: Command): PublisherConfiguration { + return { + type: 'googleGcs', + googleGcs: { + bucketName: cmd.storageName, + ...(cmd.gcsBucketRootPath && { bucketRootPath: cmd.gcsBucketRootPath }), + }, + }; + } + + /** + * Retrieves valid OpenStack Swift configuration based on the command. + */ + private static getValidOpenStackSwiftConfig( + cmd: Command, + ): PublisherConfiguration { + const missingParams = [ + 'osCredentialId', + 'osSecret', + 'osAuthUrl', + 'osSwiftUrl', + ].filter((param: string) => !cmd[param]); + + if (missingParams.length) { + throw new Error( + `openStackSwift requires the following params to be specified: ${missingParams.join( + ', ', + )}`, + ); + } + + return { + type: 'openStackSwift', + openStackSwift: { + containerName: cmd.storageName, + credentials: { + id: cmd.osCredentialId, + secret: cmd.osSecret, + }, + authUrl: cmd.osAuthUrl, + swiftUrl: cmd.osSwiftUrl, + }, + }; + } +} diff --git a/packages/techdocs-cli/src/lib/httpServer.ts b/packages/techdocs-cli/src/lib/httpServer.ts new file mode 100644 index 0000000000..0402ce4e69 --- /dev/null +++ b/packages/techdocs-cli/src/lib/httpServer.ts @@ -0,0 +1,100 @@ +/* + * 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 serveHandler from 'serve-handler'; +import http from 'http'; +import httpProxy from 'http-proxy'; +import { createLogger } from './utility'; + +export default class HTTPServer { + private readonly proxyEndpoint: string; + private readonly backstageBundleDir: string; + private readonly backstagePort: number; + private readonly mkdocsPort: number; + private readonly verbose: boolean; + + constructor( + backstageBundleDir: string, + backstagePort: number, + mkdocsPort: number, + verbose: boolean, + ) { + this.proxyEndpoint = '/api/'; + this.backstageBundleDir = backstageBundleDir; + this.backstagePort = backstagePort; + this.mkdocsPort = mkdocsPort; + this.verbose = verbose; + } + + // Create a Proxy for mkdocs server + private createProxy() { + const proxy = httpProxy.createProxyServer({ + target: `http://localhost:${this.mkdocsPort}`, + }); + + return (request: http.IncomingMessage): [httpProxy, string] => { + // If the request goes to /api/ we want to remove /api/ from the prefix of the request URL. + // e.g. ['/', 'api', pathChunks] + const [, , ...pathChunks] = request.url?.split('/') ?? []; + const forwardPath = pathChunks.join('/'); + + return [proxy, forwardPath]; + }; + } + + public async serve(): Promise { + return new Promise((resolve, reject) => { + const proxyHandler = this.createProxy(); + const server = http.createServer( + (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.url?.startsWith(this.proxyEndpoint)) { + const [proxy, forwardPath] = proxyHandler(request); + + proxy.on('error', (error: Error) => { + reject(error); + }); + + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + + request.url = forwardPath; + return proxy.web(request, response); + } + + return serveHandler(request, response, { + public: this.backstageBundleDir, + trailingSlash: true, + rewrites: [{ source: '**', destination: 'index.html' }], + }); + }, + ); + + const logger = createLogger({ verbose: false }); + server.listen(this.backstagePort, () => { + if (this.verbose) { + logger.info( + `[techdocs-preview-bundle] Running local version of Backstage at http://localhost:${this.backstagePort}`, + ); + } + resolve(server); + }); + + server.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.test.ts b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts new file mode 100644 index 0000000000..0167dec12b --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { runMkdocsServer } from './mkdocsServer'; +import { run } from './run'; + +jest.mock('./run', () => { + return { + run: jest.fn(), + }; +}); + +describe('runMkdocsServer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('docker', () => { + it('should run docker directly by default', async () => { + await runMkdocsServer({}); + + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + `${process.cwd()}:/content`, + '8000:8000', + 'serve', + '--dev-addr', + '0.0.0.0:8000', + 'spotify/techdocs', + ]), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['5678:5678', '0.0.0.0:5678']), + expect.objectContaining({}), + ); + }); + + it('should accept custom docker image', async () => { + await runMkdocsServer({ dockerImage: 'my-org/techdocs' }); + expect(run).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['my-org/techdocs']), + expect.objectContaining({}), + ); + }); + }); + + describe('mkdocs', () => { + it('should run mkdocs if specified', async () => { + await runMkdocsServer({ useDocker: false }); + + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['serve', '--dev-addr', '127.0.0.1:8000']), + expect.objectContaining({}), + ); + }); + + it('should accept port option', async () => { + await runMkdocsServer({ useDocker: false, port: '5678' }); + expect(run).toHaveBeenCalledWith( + 'mkdocs', + expect.arrayContaining(['127.0.0.1:5678']), + expect.objectContaining({}), + ); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/mkdocsServer.ts b/packages/techdocs-cli/src/lib/mkdocsServer.ts new file mode 100644 index 0000000000..60efcfaa89 --- /dev/null +++ b/packages/techdocs-cli/src/lib/mkdocsServer.ts @@ -0,0 +1,60 @@ +/* + * 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 { ChildProcess } from 'child_process'; +import { run, LogFunc } from './run'; + +export const runMkdocsServer = async (options: { + port?: string; + useDocker?: boolean; + dockerImage?: string; + stdoutLogFunc?: LogFunc; + stderrLogFunc?: LogFunc; +}): Promise => { + const port = options.port ?? '8000'; + const useDocker = options.useDocker ?? true; + const dockerImage = options.dockerImage ?? 'spotify/techdocs'; + + if (useDocker) { + return await run( + 'docker', + [ + 'run', + '--rm', + '-w', + '/content', + '-v', + `${process.cwd()}:/content`, + '-p', + `${port}:${port}`, + '-it', + dockerImage, + 'serve', + '--dev-addr', + `0.0.0.0:${port}`, + ], + { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }, + ); + } + + return await run('mkdocs', ['serve', '--dev-addr', `127.0.0.1:${port}`], { + stdoutLogFunc: options.stdoutLogFunc, + stderrLogFunc: options.stderrLogFunc, + }); +}; diff --git a/packages/techdocs-cli/src/lib/run.ts b/packages/techdocs-cli/src/lib/run.ts new file mode 100644 index 0000000000..9e2abeb617 --- /dev/null +++ b/packages/techdocs-cli/src/lib/run.ts @@ -0,0 +1,99 @@ +/* + * 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 { spawn, SpawnOptions, ChildProcess } from 'child_process'; + +export type LogFunc = (data: Buffer | string) => void; +type SpawnOptionsPartialEnv = Omit & { + env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; +}; + +// TODO: Accept log functions to pipe logs with. +// Runs a child command, returning the child process instance. +// Use it along with waitForSignal to run a long running process e.g. mkdocs serve +export const run = async ( + name: string, + args: string[] = [], + options: SpawnOptionsPartialEnv = {}, +): Promise => { + const { stdoutLogFunc, stderrLogFunc } = options; + + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: 'true', + ...(options.env ?? {}), + }; + + // Refer: https://nodejs.org/api/child_process.html#child_process_subprocess_stdio + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + + const childProcess = spawn(name, args, { + stdio: stdio, + ...options, + env, + }); + + if (stdoutLogFunc && childProcess.stdout) { + childProcess.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && childProcess.stderr) { + childProcess.stderr.on('data', stderrLogFunc); + } + + return childProcess; +}; + +// Block indefinitely and wait for a signal to kill the child process(es) +// Throw error if any child process errors +// Resolves only when all processes exit with status code 0 +export async function waitForSignal( + childProcesses: Array, +): Promise { + const promises: Array> = []; + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + childProcesses.forEach(childProcess => { + childProcess.kill(); + }); + }); + } + + childProcesses.forEach(childProcess => { + if (typeof childProcess.exitCode === 'number') { + if (childProcess.exitCode) { + throw new Error(`Non zero exit code from child process`); + } + return; + } + + promises.push( + new Promise((resolve, reject) => { + childProcess.once('error', reject); + childProcess.once('exit', resolve); + }), + ); + }); + + await Promise.all(promises); +} diff --git a/packages/techdocs-cli/src/lib/utility.ts b/packages/techdocs-cli/src/lib/utility.ts new file mode 100644 index 0000000000..87dbbfd08d --- /dev/null +++ b/packages/techdocs-cli/src/lib/utility.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. + */ +import { + RemoteProtocol, + ParsedLocationAnnotation, +} from '@backstage/techdocs-common'; +import * as winston from 'winston'; + +export const convertTechDocsRefToLocationAnnotation = ( + techdocsRef: string, +): ParsedLocationAnnotation => { + // Split on the first colon for the protocol and the rest after the first split + // is the location. + const [type, target] = techdocsRef.split(/:(.+)/) as [ + RemoteProtocol?, + string?, + ]; + + if (!type || !target) { + throw new Error( + `Can not parse --techdocs-ref ${techdocsRef}. Should be of type HOST:URL.`, + ); + } + + return { type, target }; +}; + +export const createLogger = ({ + verbose = false, +}: { + verbose: boolean; +}): winston.Logger => { + const logger = winston.createLogger({ + level: verbose ? 'verbose' : 'info', + transports: [ + new winston.transports.Console({ format: winston.format.simple() }), + ], + }); + + return logger; +}; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 25577df1dd..b44e68374d 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -45,7 +45,7 @@ "@backstage/search-common": "^0.2.1", "@backstage/integration": "^0.6.9", "@google-cloud/storage": "^5.6.0", - "@trendyol-js/openstack-swift-sdk": "^0.0.4", + "@trendyol-js/openstack-swift-sdk": "^0.0.5", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "express": "^4.17.1", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 1f7afa0855..ff88f12939 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -45,10 +45,12 @@ const createPublisherFromConfig = ({ bucketName = 'bucketName', bucketRootPath = '/', legacyUseCaseSensitiveTripletPaths = false, + sse, }: { bucketName?: string; bucketRootPath?: string; legacyUseCaseSensitiveTripletPaths?: boolean; + sse?: string; } = {}) => { const mockConfig = new ConfigReader({ techdocs: { @@ -62,6 +64,7 @@ const createPublisherFromConfig = ({ }, bucketName, bucketRootPath, + sse, }, }, legacyUseCaseSensitiveTripletPaths, @@ -171,6 +174,13 @@ describe('AwsS3Publish', () => { expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); + it('should publish a directory when sse is specified', async () => { + const publisher = createPublisherFromConfig({ + sse: 'aws:kms', + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); + it('should fail to publish a directory', async () => { const wrongPathToGeneratedDirectory = path.join( rootDir, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 5ebc2aab77..abfa8ddd24 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -62,6 +62,7 @@ export class AwsS3Publish implements PublisherBase { private readonly legacyPathCasing: boolean; private readonly logger: Logger; private readonly bucketRootPath: string; + private readonly sse?: 'aws:kms' | 'AES256'; constructor(options: { storageClient: aws.S3; @@ -69,12 +70,14 @@ export class AwsS3Publish implements PublisherBase { legacyPathCasing: boolean; logger: Logger; bucketRootPath: string; + sse?: 'aws:kms' | 'AES256'; }) { this.storageClient = options.storageClient; this.bucketName = options.bucketName; this.legacyPathCasing = options.legacyPathCasing; this.logger = options.logger; this.bucketRootPath = options.bucketRootPath; + this.sse = options.sse; } static fromConfig(config: Config, logger: Logger): PublisherBase { @@ -92,6 +95,11 @@ export class AwsS3Publish implements PublisherBase { config.getOptionalString('techdocs.publisher.awsS3.bucketRootPath') || '', ); + const sse = config.getOptionalString('techdocs.publisher.awsS3.sse') as + | 'aws:kms' + | 'AES256' + | undefined; + // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. // 1. AWS environment variables // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html @@ -138,6 +146,7 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, legacyPathCasing, logger, + sse, }); } @@ -208,6 +217,7 @@ export class AwsS3Publish implements PublisherBase { async publish({ entity, directory }: PublishRequest): Promise { const useLegacyPathCasing = this.legacyPathCasing; const bucketRootPath = this.bucketRootPath; + const sse = this.sse; // First, try to retrieve a list of all individual files currently existing let existingFiles: string[] = []; @@ -250,7 +260,8 @@ export class AwsS3Publish implements PublisherBase { bucketRootPath, ), Body: fileStream, - }; + ...(sse && { ServerSideEncryption: sse }), + } as aws.S3.PutObjectRequest; return this.storageClient.upload(params).promise(); }, diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index a463157552..7b623933e3 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -24,7 +24,11 @@ import { SwiftClient } from '@trendyol-js/openstack-swift-sdk'; import { NotFound } from '@trendyol-js/openstack-swift-sdk/lib/types'; import { Stream, Readable } from 'stream'; import { Logger } from 'winston'; -import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; +import { + getFileTreeRecursively, + getHeadersForFileExtension, + lowerCaseEntityTripletInStoragePath, +} from './helpers'; import { PublisherBase, PublishRequest, @@ -289,4 +293,67 @@ export class OpenStackSwiftPublish implements PublisherBase { return false; } } + + async migrateDocsCase({ + removeOriginal = false, + concurrency = 25, + }): Promise { + // Iterate through every file in the root of the publisher. + const allObjects = await this.getAllObjectsFromContainer(); + const limiter = createLimiter(concurrency); + await Promise.all( + allObjects.map(f => + limiter(async file => { + let newPath; + try { + newPath = lowerCaseEntityTripletInStoragePath(file); + } catch (e) { + assertError(e); + this.logger.warn(e.message); + return; + } + + // If all parts are already lowercase, ignore. + if (file === newPath) { + return; + } + + try { + this.logger.verbose(`Migrating ${file} to ${newPath}`); + await this.storageClient.copy( + this.containerName, + file, + this.containerName, + newPath, + ); + if (removeOriginal) { + await this.storageClient.delete(this.containerName, file); + } + } catch (e) { + assertError(e); + this.logger.warn(`Unable to migrate ${file}: ${e.message}`); + } + }, f), + ), + ); + } + + /** + * Returns a list of all object keys from the configured container. + */ + protected async getAllObjectsFromContainer( + { prefix } = { prefix: '' }, + ): Promise { + let objects: string[] = []; + const OSS_MAX_LIMIT = Math.pow(2, 31) - 1; + + const allObjects = await this.storageClient.list( + this.containerName, + prefix, + OSS_MAX_LIMIT, + ); + objects = allObjects.map((object: any) => object.name); + + return objects; + } } diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index c4936f6d96..62a8d9f601 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -7,7 +7,8 @@ import { AnalyticsApi } from '@backstage/core-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; -import { ErrorContext } from '@backstage/core-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { ReactElement } from 'react'; @@ -27,8 +28,8 @@ export type CollectedLogs = { // @public export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; // @public @deprecated (undocumented) @@ -109,13 +110,13 @@ export class MockErrorApi implements ErrorApi { constructor(options?: MockErrorApiOptions); // (undocumented) error$(): Observable<{ - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }>; // (undocumented) getErrors(): ErrorWithContext[]; // (undocumented) - post(error: Error, context?: ErrorContext): void; + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; // (undocumented) waitForError(pattern: RegExp, timeoutMs?: number): Promise; } diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 87601e29e2..96918b4e47 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ErrorApi, ErrorContext } from '@backstage/core-plugin-api'; +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; /** @@ -27,12 +31,12 @@ export type MockErrorApiOptions = { }; /** - * ErrorWithContext contains error and ErrorContext + * ErrorWithContext contains error and ErrorApiErrorContext * @public */ export type ErrorWithContext = { - error: Error; - context?: ErrorContext; + error: ErrorApiError; + context?: ErrorApiErrorContext; }; type Waiter = { @@ -59,7 +63,7 @@ export class MockErrorApi implements ErrorApi { constructor(private readonly options: MockErrorApiOptions = {}) {} - post(error: Error, context?: ErrorContext) { + post(error: ErrorApiError, context?: ErrorApiErrorContext) { if (this.options.collect) { this.errors.push({ error, context }); @@ -76,7 +80,10 @@ export class MockErrorApi implements ErrorApi { throw new Error(`MockErrorApi received unexpected error, ${error}`); } - error$(): Observable<{ error: Error; context?: ErrorContext }> { + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }> { return nullObservable; } diff --git a/plugins/allure/src/plugin.ts b/plugins/allure/src/plugin.ts index f5ca7beeb3..a2eaa06a38 100644 --- a/plugins/allure/src/plugin.ts +++ b/plugins/allure/src/plugin.ts @@ -23,7 +23,7 @@ import { import { AllureApiClient, allureApiRef } from './api'; export const allureRouteRef = createRouteRef({ - title: 'allure-report', + id: 'allure', }); export const allurePlugin = createPlugin({ diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index ab976dbf18..b7c152889e 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -19,12 +19,8 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const rootRoute = createRouteRef({ - icon: NoIcon, - path: '/api-docs', - title: 'APIs', + id: 'api-docs', }); export const createComponentRouteRef = createExternalRouteRef({ diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 27b629cc07..dfb3a0a79a 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -22,7 +22,7 @@ import { OAuthHandlers } from './types'; const mockResponseData = { providerInfo: { accessToken: 'ACCESS_TOKEN', - idToken: 'ID_TOKEN', + token: 'ID_TOKEN', expiresInSeconds: 10, scope: 'email', }, @@ -216,7 +216,7 @@ describe('OAuthAdapter', () => { ...mockResponseData, backstageIdentity: { id: mockResponseData.backstageIdentity.id, - idToken: 'my-id-token', + token: 'my-id-token', }, }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index a5128711bc..24b9a0f210 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -233,10 +233,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return; } - if (!identity.idToken) { - identity.idToken = await this.options.tokenIssuer.issueToken({ + if (!(identity.token || identity.idToken)) { + identity.token = await this.options.tokenIssuer.issueToken({ claims: { sub: identity.id }, }); + } else if (!identity.token && identity.idToken) { + identity.token = identity.idToken; } } diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts index 429d608bd6..2cb7d93c0b 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -48,7 +48,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -56,9 +56,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: inputBuildDefinition, @@ -72,11 +72,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -93,7 +93,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -101,9 +101,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -117,11 +117,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -138,7 +138,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -146,9 +146,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: undefined, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -162,11 +162,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.None, result: BuildResult.Succeeded, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -183,7 +183,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -191,9 +191,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.InProgress, result: undefined, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -207,11 +207,11 @@ describe('AzureDevOpsApi', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -222,7 +222,7 @@ describe('AzureDevOpsApi', () => { it('should return empty string for link', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputBuild: Build = { @@ -230,9 +230,9 @@ describe('AzureDevOpsApi', () => { buildNumber: 'Build-1', status: BuildStatus.InProgress, result: undefined, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), sourceBranch: 'refs/heads/develop', sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', definition: undefined, @@ -246,11 +246,11 @@ describe('AzureDevOpsApi', () => { link: '', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: new Date('2020-09-12T06:10:23.9325232Z'), - startTime: new Date('2020-09-12T06:15:23.9325232Z'), - finishTime: new Date('2020-09-12T06:20:23.9325232Z'), + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', source: 'refs/heads/develop (f4f78b31)', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); @@ -265,7 +265,7 @@ describe('AzureDevOpsApi', () => { const inputIdentityRef: IdentityRef = { displayName: 'Jane Doe', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', }; const inputPullRequest: GitPullRequest = { @@ -273,7 +273,7 @@ describe('AzureDevOpsApi', () => { repository: inputGitRepository, title: 'My Awesome New Feature', createdBy: inputIdentityRef, - creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + creationDate: new Date('2020-09-12T06:10:23.932Z'), sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -287,9 +287,9 @@ describe('AzureDevOpsApi', () => { pullRequestId: 7181, repoName: 'super-feature-repo', title: 'My Awesome New Feature', - uniqueName: 'DOMAINjdoe', + uniqueName: 'DOMAIN\\jdoe', createdBy: 'Jane Doe', - creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 1ed1f1d5f5..23ea3311f0 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -149,9 +149,9 @@ export function mappedRepoBuild(build: Build): RepoBuild { link: build._links?.web.href ?? '', status: build.status ?? BuildStatus.None, result: build.result ?? BuildResult.None, - queueTime: build.queueTime, - startTime: build.startTime, - finishTime: build.finishTime, + queueTime: build.queueTime?.toISOString(), + startTime: build.startTime?.toISOString(), + finishTime: build.finishTime?.toISOString(), source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, uniqueName: build.requestedFor?.uniqueName ?? 'N/A', }; @@ -167,7 +167,7 @@ export function mappedPullRequest( title: pullRequest.title, uniqueName: pullRequest.createdBy?.uniqueName ?? 'N/A', createdBy: pullRequest.createdBy?.displayName ?? 'N/A', - creationDate: pullRequest.creationDate, + creationDate: pullRequest.creationDate?.toISOString(), sourceRefName: pullRequest.sourceRefName, targetRefName: pullRequest.targetRefName, status: pullRequest.status, diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index f5d533d2d8..ce5f712392 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -152,7 +152,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', status: BuildStatus.Completed, result: BuildResult.PartiallySucceeded, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (f4f78b31)', }; @@ -162,7 +162,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2', status: BuildStatus.InProgress, result: BuildResult.None, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (13c988d4)', }; @@ -172,7 +172,7 @@ describe('createRouter', () => { link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3', status: BuildStatus.Completed, result: BuildResult.Succeeded, - queueTime: undefined, + queueTime: '2020-09-12T06:10:23.932Z', source: 'refs/heads/develop (9bedf678)', }; @@ -205,7 +205,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -218,7 +218,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'Refactoring My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/refactor-super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, @@ -231,7 +231,7 @@ describe('createRouter', () => { repoName: 'super-feature-repo', title: 'Bug Fix for My Awesome New Feature', createdBy: 'Jane Doe', - creationDate: undefined, + creationDate: '2020-09-12T06:10:23.932Z', sourceRefName: 'refs/heads/topic/fix-super-awesome-feature', targetRefName: 'refs/heads/main', status: PullRequestStatus.Active, diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index 650ab97157..61361495b3 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -138,7 +138,7 @@ export type PullRequest = { title?: string; uniqueName?: string; createdBy?: string; - creationDate?: Date; + creationDate?: string; sourceRefName?: string; targetRefName?: string; status?: PullRequestStatus; @@ -190,9 +190,9 @@ export type RepoBuild = { link?: string; status?: BuildStatus; result?: BuildResult; - queueTime?: Date; - startTime?: Date; - finishTime?: Date; + queueTime?: string; + startTime?: string; + finishTime?: string; source: string; uniqueName?: string; }; diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index 61488d4e98..844001771f 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -74,9 +74,9 @@ export type RepoBuild = { link?: string; status?: BuildStatus; result?: BuildResult; - queueTime?: Date; - startTime?: Date; - finishTime?: Date; + queueTime?: string; + startTime?: string; + finishTime?: string; source: string; uniqueName?: string; }; @@ -114,7 +114,7 @@ export type PullRequest = { title?: string; uniqueName?: string; createdBy?: string; - creationDate?: Date; + creationDate?: string; sourceRefName?: string; targetRefName?: string; status?: PullRequestStatus; diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 47a0c4debd..f33bd3bdb6 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -6,10 +6,16 @@ Website: [https://dev.azure.com/](https://dev.azure.com/) ### Azure Pipelines -Lists the top _n_ builds for a given repository where _n_ is a configurable value +Lists the top _n_ builds for a given Azure Repo where _n_ is a configurable value ![Azure Pipelines Builds Example](./docs/azure-devops-builds.png) +### Azure Repos + +Lists the top _n_ Active, Completed, or Abandoned Pull Requests for a given repository where _n_ is a configurable value + +![Azure Repos Pull Requests Example](./docs/azure-devops-pull-requests.png) + ## Setup The following sections will help you get the Azure DevOps plugin setup and running @@ -69,14 +75,55 @@ To get the Azure Pipelines component working you'll need to do the following two // ... - // Set defaultLimit to the max number of builds you would like to be able to see - // the default if not set is 10 // ... ``` +**Notes:** + +- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` proper on the `EntityAzurePipelinesContent` will set the max number of Builds you would like to see, if not set this will default to 10 + +### Azure Repos Component + +To get the Azure Repos component working you'll need to do the following two steps: + +1. First we need to add the @backstage/plugin-azure-devops package to your frontend app: + + ```bash + # From your Backstage root directory + cd packages/app + yarn add @backstage/plugin-azure-devops + ``` + +2. Second we need to add the `EntityAzureReposContent` extension to the entity page in your app: + + ```tsx + // In packages/app/src/components/catalog/EntityPage.tsx + import { + EntityAzureReposContent, + isAzureDevOpsAvailable, + } from '@backstage/plugin-azure-devops'; + + // For example in the Service section + const serviceEntityPage = ( + + // ... + + + + // ... + + ``` + +**Notes:** + +- You'll need to add the `EntityLayout.Route` above from step 2 to all the entity sections you want to see Pull Requests in. For example if you wanted to see Pull Requests when looking at Website entities then you would need to add this to the `websiteEntityPage` section. +- The `if` prop is optional on the `EntityLayout.Route`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` proper on the `EntityAzureReposContent` will set the max number of Pull Requests you would like to see, if not set this will default to 10 + ## Limitations - Currently multiple organizations are not supported diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index 96ea95e00e..cebe6595f7 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -7,19 +7,12 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { RouteRef } from '@backstage/core-plugin-api'; import { SvgIconProps } from '@material-ui/core'; // Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const azureDevOpsPlugin: BackstagePlugin< - { - azurePullRequestDashboard: RouteRef; - azurePipelinesEntityContent: RouteRef; - }, - {} ->; +export const azureDevOpsPlugin: BackstagePlugin<{}, {}>; // Warning: (ae-missing-release-tag) "AzurePullRequestsIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -40,6 +33,15 @@ export const EntityAzurePipelinesContent: ({ defaultLimit?: number | undefined; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "EntityAzurePullRequestsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityAzurePullRequestsContent: ({ + defaultLimit, +}: { + defaultLimit?: number | undefined; +}) => JSX.Element; + // Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops/docs/azure-devops-builds.png b/plugins/azure-devops/docs/azure-devops-builds.png index 4788a185ed..6ff93dc7e4 100644 Binary files a/plugins/azure-devops/docs/azure-devops-builds.png and b/plugins/azure-devops/docs/azure-devops-builds.png differ diff --git a/plugins/azure-devops/docs/azure-devops-pull-requests.png b/plugins/azure-devops/docs/azure-devops-pull-requests.png new file mode 100644 index 0000000000..ce1b2a4267 Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-pull-requests.png differ diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index 235304c9fa..3f1876b974 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -37,6 +37,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "humanize-duration": "^3.27.0", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index a6967d01e4..80d36f0adf 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -16,6 +16,8 @@ import { DashboardPullRequest, + PullRequest, + PullRequestOptions, RepoBuild, RepoBuildOptions, Team, @@ -36,6 +38,12 @@ export interface AzureDevOpsApi { options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }>; + getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }>; + getDashboardPullRequests( projectName: string, ): Promise; diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index 08da6305e5..28d7e19cff 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -16,6 +16,8 @@ import { DashboardPullRequest, + PullRequest, + PullRequestOptions, RepoBuild, RepoBuildOptions, Team, @@ -42,9 +44,35 @@ export class AzureDevOpsClient implements AzureDevOpsApi { repoName: string, options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }> { - const items = await this.get( - `repo-builds/${projectName}/${repoName}?top=${options?.top}`, - ); + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + const urlSegment = `repo-builds/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); + return { items }; + } + + public async getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }> { + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + if (options?.status) { + queryString.append('status', options.status.toString()); + } + const urlSegment = `pull-requests/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); return { items }; } diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx new file mode 100644 index 0000000000..bd19331f6e --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx @@ -0,0 +1,40 @@ +/* + * 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 { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const AzurePipelinesIcon = (props: SvgIconProps) => ( + + + + +); diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts new file mode 100644 index 0000000000..4eb161f7bc --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { AzurePipelinesIcon } from './AzurePipelinesIcon'; diff --git a/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx index 3159556788..bb4cb8c081 100644 --- a/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx +++ b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx @@ -20,9 +20,51 @@ import React from 'react'; export const AzurePullRequestsIcon = (props: SvgIconProps) => ( + + + + ); diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx index 76a19155ba..84980b0b51 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx @@ -56,7 +56,7 @@ const generateTestData = (rows = 10): RepoBuild[] => { title: `backstage ci - 1.0.0-preview-${rows - i}`, status, result, - queueTime: new Date(Date.now() - i * 60000), + queueTime: new Date(Date.now() - i * 60000).toISOString(), source: 'refs/heads/main', link: '', }); diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx index 5667c65711..463bf15d78 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -33,8 +33,10 @@ import { TableColumn, } from '@backstage/core-components'; +import { AzurePipelinesIcon } from '../AzurePipelinesIcon'; import { DateTime } from 'luxon'; import React from 'react'; +import { getDurationFromDates } from '../../utils/getDurationFromDates'; export const getBuildResultComponent = (result: number | undefined) => { switch (result) { @@ -144,12 +146,27 @@ const columns: TableColumn[] = [ ), }, + { + title: 'Duration', + field: 'queueTime', + width: 'auto', + render: (row: Partial) => ( + + + {getDurationFromDates(row.startTime, row.finishTime)} + + + ), + }, { title: 'Age', field: 'queueTime', width: 'auto', render: (row: Partial) => - DateTime.fromJSDate(row.queueTime ?? new Date()).toRelative(), + (row.queueTime + ? DateTime.fromISO(row.queueTime) + : DateTime.now() + ).toRelative(), }, ]; @@ -178,7 +195,13 @@ export const BuildTable = ({ items, loading, error }: BuildTableProps) => { pageSize: 5, showEmptyDataSourceMessage: !loading, }} - title={`Builds (${items ? items.length : 0})`} + title={ + + + + Azure Pipelines - Builds ({items ? items.length : 0}) + + } data={items ?? []} /> ); diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx new file mode 100644 index 0000000000..3d1a3ef77a --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx @@ -0,0 +1,26 @@ +/* + * 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 { PullRequestTable } from '../PullRequestTable/PullRequestTable'; +import React from 'react'; + +export const EntityPageAzurePullRequests = ({ + defaultLimit, +}: { + defaultLimit?: number; +}) => { + return ; +}; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts new file mode 100644 index 0000000000..2ec6c5bf04 --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { EntityPageAzurePullRequests } from './EntityPageAzurePullRequests'; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx new file mode 100644 index 0000000000..f516bfb0e5 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx @@ -0,0 +1,57 @@ +/* + * 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 { Button, ButtonGroup } from '@material-ui/core'; + +import { PullRequestStatus } from '@backstage/plugin-azure-devops-common'; +import React from 'react'; + +export const PullRequestStatusButtonGroup = ({ + status, + setStatus, +}: { + status: PullRequestStatus; + setStatus: (pullRequestStatus: PullRequestStatus) => void; +}) => { + return ( + + + + + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts new file mode 100644 index 0000000000..10a667803a --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { PullRequestStatusButtonGroup } from './PullRequestStatusButtonGroup'; diff --git a/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx new file mode 100644 index 0000000000..4365b796bc --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx @@ -0,0 +1,139 @@ +/* + * 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 { Box, Chip } from '@material-ui/core'; +import { + Link, + ResponseErrorPanel, + Table, + TableColumn, +} from '@backstage/core-components'; +import { + PullRequest, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; +import React, { useState } from 'react'; + +import { AzurePullRequestsIcon } from '../AzurePullRequestsIcon'; +import { DateTime } from 'luxon'; +import { PullRequestStatusButtonGroup } from '../PullRequestStatusButtonGroup'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { usePullRequests } from '../../hooks/usePullRequests'; + +const columns: TableColumn[] = [ + { + title: 'ID', + field: 'pullRequestId', + highlight: false, + width: 'auto', + }, + { + title: 'Title', + field: 'title', + width: 'auto', + render: (row: Partial) => ( + + {row.title} + {row.isDraft && ( + + + + )} + + ), + }, + { + title: 'Source', + field: 'sourceRefName', + width: 'auto', + }, + { + title: 'Target', + field: 'targetRefName', + width: 'auto', + }, + { + title: 'Created By', + field: 'createdBy', + width: 'auto', + }, + { + title: 'Created', + field: 'creationDate', + width: 'auto', + render: (row: Partial) => + (row.creationDate + ? DateTime.fromISO(row.creationDate) + : DateTime.now() + ).toRelative(), + }, +]; + +type PullRequestTableProps = { + defaultLimit?: number; +}; + +export const PullRequestTable = ({ defaultLimit }: PullRequestTableProps) => { + const [pullRequestStatusState, setPullRequestStatusState] = + useState(PullRequestStatus.Active); + const { entity } = useEntity(); + + const { items, loading, error } = usePullRequests( + entity, + defaultLimit, + pullRequestStatusState, + ); + + if (error) { + return ( +
+ +
+ ); + } + + return ( + + + + Azure Repos - Pull Requests ({items ? items.length : 0}) + + + + + } + data={items ?? []} + /> + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestTable/index.ts b/plugins/azure-devops/src/components/PullRequestTable/index.ts new file mode 100644 index 0000000000..956e8afa2d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { PullRequestTable } from './PullRequestTable'; diff --git a/plugins/azure-devops/src/hooks/usePullRequests.ts b/plugins/azure-devops/src/hooks/usePullRequests.ts new file mode 100644 index 0000000000..178bf0495f --- /dev/null +++ b/plugins/azure-devops/src/hooks/usePullRequests.ts @@ -0,0 +1,58 @@ +/* + * 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 { + PullRequest, + PullRequestOptions, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; + +import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; + +export function usePullRequests( + entity: Entity, + defaultLimit?: number, + requestedStatus?: PullRequestStatus, +): { + items?: PullRequest[]; + loading: boolean; + error?: Error; +} { + const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP; + const status = requestedStatus ?? PullRequestStatus.Active; + const options: PullRequestOptions = { + top, + status, + }; + + const api = useApi(azureDevOpsApiRef); + const { project, repo } = useProjectRepoFromEntity(entity); + + const { value, loading, error } = useAsync(() => { + return api.getPullRequests(project, repo, options); + }, [api, project, repo, top, status]); + + return { + items: value?.items, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 63e0d4923d..622f5560b9 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -17,6 +17,7 @@ export { azureDevOpsPlugin, EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, AzurePullRequestsPage, } from './plugin'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index 06c79b1742..003c7b7eee 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -17,6 +17,7 @@ import { azurePipelinesEntityContentRouteRef, azurePullRequestDashboardRouteRef, + azurePullRequestsEntityContentRouteRef, } from './routes'; import { createApiFactory, @@ -44,10 +45,6 @@ export const azureDevOpsPlugin = createPlugin({ new AzureDevOpsClient({ discoveryApi, identityApi }), }), ], - routes: { - azurePullRequestDashboard: azurePullRequestDashboardRouteRef, - azurePipelinesEntityContent: azurePipelinesEntityContentRouteRef, - }, }); export const AzurePullRequestsPage = azureDevOpsPlugin.provide( @@ -69,3 +66,14 @@ export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( mountPoint: azurePipelinesEntityContentRouteRef, }), ); + +export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'EntityAzurePullRequestsContent', + component: () => + import('./components/EntityPageAzurePullRequests').then( + m => m.EntityPageAzurePullRequests, + ), + mountPoint: azurePullRequestsEntityContentRouteRef, + }), +); diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts index 9573ff5c69..881ed8cc30 100644 --- a/plugins/azure-devops/src/routes.ts +++ b/plugins/azure-devops/src/routes.ts @@ -17,10 +17,14 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const azurePullRequestDashboardRouteRef = createRouteRef({ + id: 'azure-pull-request-dashboard', path: '', - title: 'Azure Pull Requests', }); export const azurePipelinesEntityContentRouteRef = createRouteRef({ - title: 'Azure Pipelines Entity Content', + id: 'azure-pipelines-entity-content', +}); + +export const azurePullRequestsEntityContentRouteRef = createRouteRef({ + id: 'azure-pull-requests-entity-content', }); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.test.ts b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts new file mode 100644 index 0000000000..fac0e917d0 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { getDurationFromDates } from './getDurationFromDates'; + +describe('getDurationFromDates', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-15T09:45:25.0000000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('getDurationFromDates with valid startTime and valid finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(startTime, finishTime); + + expect(result).toEqual('1h, 30m'); + }); + }); + + describe('getDurationFromDates with valid startTime and undefined finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + + const result = getDurationFromDates(startTime, undefined); + + expect(result).toEqual('15m, 25s'); + }); + }); + + describe('getDurationFromDates with undefined startTime and valid finishTime', () => { + it('should return empty result', () => { + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(undefined, finishTime); + + expect(result).toEqual(''); + }); + }); + + describe('getDurationFromDates with undefined startTime and undefined finishTime', () => { + it('should return empty result', () => { + const result = getDurationFromDates(undefined, undefined); + + expect(result).toEqual(''); + }); + }); +}); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.ts b/plugins/azure-devops/src/utils/getDurationFromDates.ts new file mode 100644 index 0000000000..789849f975 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.ts @@ -0,0 +1,56 @@ +/* + * 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 { DateTime, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; + +export const getDurationFromDates = ( + startTime?: string, + finishTime?: string, +): string => { + if (!startTime || (!startTime && !finishTime)) { + return ''; + } + + const start = DateTime.fromISO(startTime); + const finish = finishTime ? DateTime.fromISO(finishTime) : DateTime.now(); + + const formatted = Interval.fromDateTimes(start, finish) + .toDuration() + .valueOf(); + + const shortEnglishHumanizer = humanizeDuration.humanizer({ + language: 'shortEn', + languages: { + shortEn: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + }, + }, + }); + + return shortEnglishHumanizer(formatted, { + largest: 2, + round: true, + spacer: '', + }); +}; diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index d81b042ce5..432a485a6b 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -17,7 +17,6 @@ import { generatePath } from 'react-router'; import { ResponseError } from '@backstage/errors'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { entityRoute } from '@backstage/plugin-catalog-react'; import { BadgesApi, BadgeSpec } from './types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; @@ -53,7 +52,7 @@ export class BadgesClient implements BadgesApi { private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); + const path = generatePath(`:kind/:namespace/:name`, routeParams); return `${await this.discoveryApi.getBaseUrl( 'badges', )}/entity/${path}/badge-specs`; diff --git a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js new file mode 100644 index 0000000000..d4cb87bd82 --- /dev/null +++ b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js @@ -0,0 +1,45 @@ +/* + * 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. + */ + +exports.up = async function up(knex) { + await knex.schema.alterTable('metadata', table => { + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.alterTable('metadata', table => { + table + .dropColumn('size') + .dropColumn('start_date') + .dropColumn('end_date') + .dropColumn('responsible'); + }); +}; diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts index 7cce5ee414..3afde44d5e 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts @@ -18,14 +18,17 @@ import { DatabaseHandler } from './DatabaseHandler'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; const bazaarProject: any = { - name: 'name', - entityRef: 'ref', + name: 'n1', + entityRef: 'ref1', community: '', status: 'proposed', announcement: 'a', membersCount: 0, + startDate: '2021-11-07T13:27:00.000Z', + endDate: null, + size: 'small', + responsible: 'r', }; - describe('DatabaseHandler', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], @@ -33,18 +36,40 @@ describe('DatabaseHandler', () => { async function createDatabaseHandler(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); - return await DatabaseHandler.create({ database: knex }); + return { + knex, + dbHandler: await DatabaseHandler.create({ database: knex }), + }; } it.each(databases.eachSupportedId())( - 'should do a full sync with the locations on connect, %p', + 'should insert and get entity, %p', async databaseId => { - const db = await createDatabaseHandler(databaseId); - await db.insertMetadata(bazaarProject); + const { knex, dbHandler } = await createDatabaseHandler(databaseId); - const entities = await db.getEntities(); - expect(entities.length).toEqual(1); - expect(entities[0].entity_ref).toEqual(bazaarProject.entityRef); + await knex('metadata').insert({ + entity_ref: bazaarProject.entityRef, + name: bazaarProject.name, + announcement: bazaarProject.announcement, + community: bazaarProject.community, + status: bazaarProject.status, + updated_at: new Date().toISOString(), + start_date: bazaarProject.startDate, + end_date: bazaarProject.endDate, + size: bazaarProject.size, + responsible: bazaarProject.responsible, + }); + + const res = await dbHandler.getMetadata('ref1'); + + expect(res).toHaveLength(1); + expect(res[0].announcement).toEqual('a'); + expect(res[0].community).toEqual(''); + expect(res[0].status).toEqual('proposed'); + expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z'); + expect(res[0].end_date).toEqual(null); + expect(res[0].size).toEqual('small'); + expect(res[0].responsible).toEqual('r'); }, 60_000, ); diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.ts index d4da6e65e7..60f579af2d 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.ts @@ -43,6 +43,20 @@ export class DatabaseHandler { this.database = options.database; } + private columns = [ + 'members.entity_ref', + 'metadata.entity_ref', + 'metadata.name', + 'metadata.announcement', + 'metadata.status', + 'metadata.updated_at', + 'metadata.community', + 'metadata.size', + 'metadata.start_date', + 'metadata.end_date', + 'metadata.responsible', + ]; + async getMembers(entityRef: string) { return await this.database .select('*') @@ -72,25 +86,25 @@ export class DatabaseHandler { 'coalesce(count(members.entity_ref), 0) as members_count', ); - const columns = [ - 'members.entity_ref', - 'metadata.entity_ref', - 'metadata.name', - 'metadata.announcement', - 'metadata.status', - 'metadata.updated_at', - 'metadata.community', - ]; - return await this.database('metadata') - .select([...columns, coalesce]) + .select([...this.columns, coalesce]) .where({ 'metadata.entity_ref': entityRef }) - .groupBy(columns) + .groupBy(this.columns) .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); } async insertMetadata(bazaarProject: any) { - const { name, entityRef, community, announcement, status } = bazaarProject; + const { + name, + entityRef, + community, + announcement, + status, + size, + startDate, + endDate, + responsible, + } = bazaarProject; await this.database .insert({ @@ -100,12 +114,25 @@ export class DatabaseHandler { announcement: announcement, status: status, updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, }) .into('metadata'); } async updateMetadata(bazaarProject: any) { - const { entityRef, community, announcement, status } = bazaarProject; + const { + entityRef, + community, + announcement, + status, + size, + startDate, + endDate, + responsible, + } = bazaarProject; return await this.database('metadata') .where({ entity_ref: entityRef }) @@ -114,6 +141,10 @@ export class DatabaseHandler { community: community, status: status, updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, }); } @@ -128,18 +159,9 @@ export class DatabaseHandler { 'coalesce(count(members.entity_ref), 0) as members_count', ); - const columns = [ - 'members.entity_ref', - 'metadata.entity_ref', - 'metadata.name', - 'metadata.announcement', - 'metadata.status', - 'metadata.updated_at', - 'metadata.community', - ]; return await this.database('metadata') - .select([...columns, coalesce]) - .groupBy(columns) + .select([...this.columns, coalesce]) + .groupBy(this.columns) .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); } } diff --git a/plugins/bazaar/README.md b/plugins/bazaar/README.md index cd26f01997..f07e751984 100644 --- a/plugins/bazaar/README.md +++ b/plugins/bazaar/README.md @@ -58,7 +58,7 @@ const overviewContent = ( -+ ++ + + @@ -75,12 +75,23 @@ The latest modified Bazaar projects are displayed in the Bazaar landing page, lo ### Workflow -To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. You will be asked to add an announcement for new team members. The purpose of the announcement is for you to present your ideas and what skills you are looking for. Further you need to provide the status of the project. +To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. + +The following fields are mandatory: + +- announcement - present your idea and what skills you are looking for +- status - whether or not the project has started +- size - small, medium or large +- responsible - main contact person of the project + +The other fields are: + +- start date +- end date +- community link - link where the project members can chat, e.g. Teams or Discord link When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it. -![workflow](media/bazaar_demo.gif) - ### Database The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project. @@ -92,6 +103,10 @@ The metadata related to the Bazaar is stored in a database. Right now there are - announcement - announcement of the project and its current need of skills/team member - status - status of the project, 'proposed' or 'ongoing' - updated_at - date when the Bazaar information was last modified (ISO 8601 format) +- size - small, medium or large +- start_date - date when the project is estimated to start (ISO 8601 format) +- end_date - date when the project is estimated to end (ISO 8601 format) +- responsible - main contact person of the project **members**: @@ -107,12 +122,8 @@ The metadata related to the Bazaar is stored in a database. Right now there are - Bazaar landing page - - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities, projects or tags you are following etc. - - Make it possible to sort the project based on the number of members - -- Bazaar card - - - Make it possible to follow tags/projects + - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities etc. + - Make it possible to sort the project based on e.g. the number of members - Bazaar tab on the EntityPage diff --git a/plugins/bazaar/media/bazaar_demo.gif b/plugins/bazaar/media/bazaar_demo.gif deleted file mode 100644 index c5383de477..0000000000 Binary files a/plugins/bazaar/media/bazaar_demo.gif and /dev/null differ diff --git a/plugins/bazaar/media/bazaar_pr_fullscreen.png b/plugins/bazaar/media/bazaar_pr_fullscreen.png index d0187977db..6c5cf1277b 100644 Binary files a/plugins/bazaar/media/bazaar_pr_fullscreen.png and b/plugins/bazaar/media/bazaar_pr_fullscreen.png differ diff --git a/plugins/bazaar/package.json b/plugins/bazaar/package.json index 4d18784d2d..094741bf36 100644 --- a/plugins/bazaar/package.json +++ b/plugins/bazaar/package.json @@ -30,7 +30,9 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/pickers": "^3.3.10", "@testing-library/jest-dom": "^5.10.1", + "@date-io/luxon": "1.x", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx index 82d682b4df..310ffe051d 100644 --- a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx +++ b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx @@ -16,11 +16,11 @@ import React, { useState, useEffect } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; -import { SubmitHandler } from 'react-hook-form'; +import { UseFormReset, UseFormGetValues } from 'react-hook-form'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; import { ProjectSelector } from '../ProjectSelector'; -import { BazaarProject, FormValues, Status } from '../../types'; +import { BazaarProject, FormValues, Size, Status } from '../../types'; import { bazaarApiRef } from '../../api'; type Props = { @@ -52,6 +52,10 @@ export const AddProjectDialog = ({ community: '', announcement: '', status: 'proposed' as Status, + size: 'medium' as Size, + responsible: '', + startDate: null, + endDate: null, }; const handleListItemClick = (entity: Entity) => { @@ -63,9 +67,9 @@ export const AddProjectDialog = ({ handleClose(); }; - const handleSave: SubmitHandler = async ( - getValues: any, - reset: any, + const handleSave: any = async ( + getValues: UseFormGetValues, + reset: UseFormReset, ) => { const formValues = getValues(); @@ -77,6 +81,10 @@ export const AddProjectDialog = ({ status: formValues.status, community: formValues.community, membersCount: 0, + size: formValues.size, + startDate: formValues.startDate ?? null, + endDate: formValues.endDate ?? null, + responsible: formValues.responsible, } as BazaarProject); fetchBazaarProjects(); diff --git a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx new file mode 100644 index 0000000000..db564a7b68 --- /dev/null +++ b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx @@ -0,0 +1,72 @@ +/* + * 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 React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import { Controller, Control, UseFormSetValue } from 'react-hook-form'; +import { FormValues } from '../../types'; +import { + KeyboardDatePicker, + MuiPickersUtilsProvider, +} from '@material-ui/pickers'; +import LuxonUtils from '@date-io/luxon'; +import { IconButton } from '@material-ui/core'; +import ClearIcon from '@material-ui/icons/Clear'; + +type Props = { + name: 'startDate' | 'endDate'; + control: Control; + setValue: UseFormSetValue; +}; + +export const DateSelector = ({ name, control, setValue }: Props) => { + const label = `${ + name.charAt(0).toLocaleUpperCase('en-US') + name.slice(1, name.indexOf('D')) + } date`; + + return ( + ( + + + { + setValue(name, date?.toISO()); + }} + InputProps={{ + endAdornment: ( + setValue(name, null)}> + + + ), + }} + InputAdornmentProps={{ + position: 'start', + }} + /> + + + )} + /> + ); +}; diff --git a/plugins/bazaar/src/components/DateSelector/index.ts b/plugins/bazaar/src/components/DateSelector/index.ts new file mode 100644 index 0000000000..9bdb078164 --- /dev/null +++ b/plugins/bazaar/src/components/DateSelector/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { DateSelector } from './DateSelector'; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx new file mode 100644 index 0000000000..b37e734453 --- /dev/null +++ b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx @@ -0,0 +1,67 @@ +/* + * 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 React from 'react'; +import { Control, UseFormSetValue } from 'react-hook-form'; +import { FormValues } from '../../types'; +import { Typography } from '@material-ui/core'; +import { DateSelector } from '../DateSelector/DateSelector'; + +type Props = { + control: Control; + setValue: UseFormSetValue; +}; + +export const DoubleDateSelector = ({ control, setValue }: Props) => { + return ( +
+
+ +
+ + + - + +
+ +
+
+ ); +}; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/index.ts b/plugins/bazaar/src/components/DoubleDateSelector/index.ts new file mode 100644 index 0000000000..9162827b35 --- /dev/null +++ b/plugins/bazaar/src/components/DoubleDateSelector/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { DoubleDateSelector } from './DoubleDateSelector'; diff --git a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx index 172bdf548d..41c9e474d4 100644 --- a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx +++ b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx @@ -18,8 +18,9 @@ import React, { useState, useEffect } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; -import { BazaarProject, FormValues } from '../../types'; +import { BazaarProject, FormValues, Size, Status } from '../../types'; import { bazaarApiRef } from '../../api'; +import { UseFormGetValues } from 'react-hook-form'; type Props = { entity: Entity; @@ -41,6 +42,10 @@ export const EditProjectDialog = ({ announcement: bazaarProject.announcement, community: bazaarProject.community, status: bazaarProject.status, + size: bazaarProject.size, + startDate: bazaarProject?.startDate ?? null, + endDate: bazaarProject?.endDate ?? null, + responsible: bazaarProject.responsible, }); const bazaarApi = useApi(bazaarApiRef); @@ -50,19 +55,27 @@ export const EditProjectDialog = ({ announcement: bazaarProject.announcement, community: bazaarProject.community, status: bazaarProject.status, + size: bazaarProject.size, + startDate: bazaarProject?.startDate ?? null, + endDate: bazaarProject?.endDate ?? null, + responsible: bazaarProject.responsible, }); }, [bazaarProject]); - const handleSave: any = async (getValues: any, _: any) => { + const handleSave: any = async (getValues: UseFormGetValues) => { const formValues = getValues(); const updateResponse = await bazaarApi.updateMetadata({ name: entity.metadata.name, entityRef: stringifyEntityRef(entity), announcement: formValues.announcement, - status: formValues.status, + status: formValues.status as Status, community: formValues.community, membersCount: bazaarProject.membersCount, + size: formValues.size as Size, + startDate: formValues?.startDate ?? null, + endDate: formValues?.endDate ?? null, + responsible: formValues.responsible, }); if (updateResponse.status === 'ok') fetchBazaarProject(); diff --git a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx index 6bf844d592..acae8f5342 100644 --- a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx +++ b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx @@ -121,6 +121,10 @@ export const EntityBazaarInfoCard = () => { status: metadata.status, updatedAt: metadata.updated_at, membersCount: metadata.members_count, + size: metadata.size, + startDate: metadata.start_date, + endDate: metadata.end_date, + responsible: metadata.responsible, } as BazaarProject; } } @@ -259,7 +263,7 @@ export const EntityBazaarInfoCard = () => { - + {bazaarProject?.value?.announcement ? bazaarProject?.value?.announcement @@ -278,17 +282,11 @@ export const EntityBazaarInfoCard = () => { - - - - - - - + {' '} {members?.value?.length ? ( - members.value.slice(0, 3).map((member: Member) => { + members.value.slice(0, 7).map((member: Member) => { return (
{ fontSize: '8px', float: 'left', marginRight: '0.3rem', + marginBottom: '0.25rem', }} picture={member.picture} /> @@ -317,6 +316,44 @@ export const EntityBazaarInfoCard = () => { )} + + + + + + + + + + + {bazaarProject?.value?.size} + + + + + + + + {bazaarProject?.value?.startDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject?.value?.endDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject?.value?.responsible || ''} + + + diff --git a/plugins/bazaar/src/components/InputField/InputField.tsx b/plugins/bazaar/src/components/InputField/InputField.tsx index 4d284ff82c..9932e5b1fc 100644 --- a/plugins/bazaar/src/components/InputField/InputField.tsx +++ b/plugins/bazaar/src/components/InputField/InputField.tsx @@ -19,13 +19,18 @@ import { Controller, Control, FieldError } from 'react-hook-form'; import { TextField } from '@material-ui/core'; import { FormValues } from '../../types'; +type Rules = { + required: boolean; + pattern?: any; +}; + type Props = { - inputType: 'announcement' | 'community'; + inputType: 'announcement' | 'community' | 'responsible'; error?: FieldError | undefined; control: Control; helperText?: string; placeholder?: string; - rules?: Object; + rules?: Rules | undefined; }; export const InputField = ({ @@ -47,6 +52,7 @@ export const InputField = ({ render={({ field }) => ( ; - name: 'announcement' | 'status'; + name: 'status' | 'size'; error?: FieldError | undefined; }; @@ -42,14 +42,17 @@ export const InputSelector = ({ name, options, control, error }: Props) => { render={({ field }) => ( {label} = { input: Input; output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; + metadata?: TemplateMetadata; }; // Warning: (ae-missing-release-tag) "CatalogEntityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -432,6 +433,8 @@ export interface TaskSpecV1beta2 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: string; }; @@ -454,6 +457,8 @@ export interface TaskSpecV1beta3 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: JsonValue; }; @@ -558,4 +563,9 @@ export class TemplateActionRegistry { action: TemplateAction, ): void; } + +// @public +export type TemplateMetadata = { + name: string; +}; ``` diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 4befec5a81..755b435149 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -144,6 +144,7 @@ describe('fetch:template', () => { name: 'test-project', count: 1234, itemList: ['first', 'second', 'third'], + showDummyFile: false, }, }); @@ -163,6 +164,10 @@ describe('fetch:template', () => { }, '.${{ values.name }}': '${{ values.itemList | dump }}', 'a-binary-file.png': aBinaryFile, + '{% if values.showDummyFile %}dummy-file.txt{% else %}{% endif %}': + 'dummy file', + '${{ "dummy-file2.txt" if values.showDummyFile else "" }}': + 'some dummy file', }, }); @@ -181,6 +186,18 @@ describe('fetch:template', () => { ); }); + it('skips empty filename', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty filename syntax #2', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file2.txt`), + ).resolves.toEqual(false); + }); + it('copies files with no templating in names or content successfully', async () => { await expect( fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index bb568c25e6..47bfcccfdf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -241,6 +241,11 @@ export function createFetchTemplateAction(options: { localOutputPath = templater.renderString(localOutputPath, context); } const outputPath = resolvePath(outputDir, localOutputPath); + // variables have been expanded to make an empty file name + // this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip + if (outputDir === outputPath) { + continue; + } if (!renderContents && !extension) { ctx.logger.info( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index ca3459d29c..52149be724 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -18,6 +18,7 @@ import { Logger } from 'winston'; import { Writable } from 'stream'; import { JsonValue, JsonObject } from '@backstage/types'; import { Schema } from 'jsonschema'; +import { TemplateMetadata } from '../tasks/types'; type PartialJsonObject = Partial; type PartialJsonValue = PartialJsonObject | JsonValue | undefined; @@ -44,6 +45,8 @@ export type ActionContext = { * Creates a temporary directory for use by the action, which is then cleaned up automatically. */ createTemporaryDirectory(): Promise; + + metadata?: TemplateMetadata; }; export type TemplateAction = { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts index d07ff29fae..1f7877791c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts @@ -146,6 +146,30 @@ describe('DefaultWorkflowRunner', () => { expect(fakeActionHandler).toHaveBeenCalledTimes(1); }); + + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + ], + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); }); describe('conditionals', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts index 77d0a7be09..5582b53023 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts @@ -225,6 +225,12 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { input: JSON.stringify(input, null, 2), }); + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, logger: taskLogger, @@ -242,6 +248,7 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutputs[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts index 91e772dc4e..9b05c43831 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts @@ -28,6 +28,7 @@ describe('LegacyWorkflowRunner', () => { let runner: HandlebarsWorkflowRunner; const logger = getVoidLogger(); let actionRegistry = new TemplateActionRegistry(); + let fakeActionHandler: jest.Mock; const integrations = ScmIntegrations.fromConfig( new ConfigReader({ @@ -59,6 +60,11 @@ describe('LegacyWorkflowRunner', () => { ctx.output('badOutput', false); }, }); + fakeActionHandler = jest.fn(); + actionRegistry.register({ + id: 'test-metadata-action', + handler: fakeActionHandler, + }); runner = new HandlebarsWorkflowRunner({ actionRegistry, @@ -87,6 +93,30 @@ describe('LegacyWorkflowRunner', () => { ); }); + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'backstage.io/v1beta2', + steps: [ + { + id: 'test', + name: 'name', + action: 'test-metadata-action', + input: { foo: 1 }, + }, + ], + output: {}, + values: {}, + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); + describe('templating', () => { it('should template the output', async () => { const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 587a93e1f2..c66d967103 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -228,6 +228,12 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const tmpDirs = new Array(); const stepOutput: { [outputName: string]: JsonValue } = {}; + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, input, @@ -244,6 +250,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutput[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts index 510c862dae..f563a08df6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts @@ -34,4 +34,5 @@ export type { TaskContext, TaskStore, DispatchResult, + TemplateMetadata, } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 6cfd914bd2..b492f8ad2c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -69,6 +69,15 @@ export type SerializedTaskEvent = { createdAt: string; }; +/** + * TemplateMetadata + * + * @public + */ +export type TemplateMetadata = { + name: string; +}; + /** * TaskSpecV1beta2 * @@ -86,6 +95,7 @@ export interface TaskSpecV1beta2 { if?: string | boolean; }>; output: { [name: string]: string }; + metadata?: TemplateMetadata; } export interface TaskStep { @@ -107,6 +117,7 @@ export interface TaskSpecV1beta3 { parameters: JsonObject; steps: TaskStep[]; output: { [name: string]: JsonValue }; + metadata?: TemplateMetadata; } /** diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2ae30593f2..b8f2968287 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -209,6 +209,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, } : { apiVersion: template.apiVersion, @@ -220,6 +221,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, }; } else { throw new InputError( diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1d8bc4a0ea..594a8e6423 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -52,7 +52,7 @@ "git-url-parse": "^11.6.0", "humanize-duration": "^3.25.1", "immer": "^9.0.1", - "json-schema": "^0.3.0", + "json-schema": "^0.4.0", "lodash": "^4.17.21", "luxon": "^2.0.2", "qs": "^6.9.4", diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 9b4861fc1e..df5868c9ea 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -24,5 +24,5 @@ export const registerComponentRouteRef = createExternalRouteRef({ }); export const rootRouteRef = createRouteRef({ - title: 'Create new entity', + id: 'scaffolder', }); diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 0e0ad01fca..c211c48e53 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -80,6 +80,7 @@ export const searchApiRef: ApiRef; // // @public (undocumented) export const SearchBar: ({ + autoFocus, className, debounceTime, placeholder, @@ -89,10 +90,12 @@ export const SearchBar: ({ // // @public @deprecated (undocumented) export const SearchBarNext: ({ + autoFocus, className, debounceTime, placeholder, }: { + autoFocus?: boolean | undefined; className?: string | undefined; debounceTime?: number | undefined; placeholder?: string | undefined; diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx index 2c990e512c..72b8429026 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx @@ -62,3 +62,22 @@ export const CustomPlaceholder = () => { ); }; + +export const Focused = () => { + return ( + + {/* @ts-ignore (defaultValue requires more than what is used here) */} + + + + + {/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */} + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index 53802899eb..4ab680d8fa 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -30,9 +30,11 @@ type PresenterProps = { onSubmit?: () => void; className?: string; placeholder?: string; + autoFocus?: boolean; }; export const SearchBarBase = ({ + autoFocus, value, onChange, onSubmit, @@ -60,6 +62,9 @@ export const SearchBarBase = ({ return ( ) => + wrapInTestApp( + <> + + , + { mountedRoutes: { '/search': rootRouteRef } }, + ), + ], +}; + +const mockSearchApi = { + query: () => + Promise.resolve({ + results: [ + { + type: 'custom-result-item', + document: { + location: 'search/search-result-1', + title: 'Search Result 1', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-2', + title: 'Search Result 2', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-3', + title: 'Search Result 3', + text: 'some text from the search result', + }, + }, + ], + }), +}; + +const apiRegistry = () => ApiRegistry.from([[searchApiRef, mockSearchApi]]); + +export const Default = () => { + const [open, setOpen] = useState(false); + const toggleModal = (): void => setOpen(prevState => !prevState); + + return ( + + + + + ); +}; diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index feabbc55af..75d4b07a3c 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -68,7 +68,7 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { setValue(prevValue => (prevValue !== term ? term : prevValue)); }, [term]); - useDebounce(() => setTerm(value), 1000, [value]); + useDebounce(() => setTerm(value), 500, [value]); const handleQuery = (newValue: string) => { setValue(newValue); @@ -114,7 +114,10 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { alignItems="center" > - + View Full Results diff --git a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx index f1850772bb..12f07530ef 100644 --- a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx +++ b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx @@ -25,7 +25,7 @@ const useStyles = makeStyles(theme => ({ display: 'flex', justifyContent: 'space-between', gap: theme.spacing(2), - margin: theme.spacing(4), + margin: theme.spacing(2, 0), }, })); @@ -44,9 +44,8 @@ export const SearchResultPager = () => { disabled={!fetchPreviousPage} onClick={fetchPreviousPage} startIcon={} - size="small" > - Back + Previous diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index b8c016fb36..9dda2a84bb 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -26,13 +26,11 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/search', - title: 'search', + id: 'search', }); export const rootNextRouteRef = createRouteRef({ - path: '/search-next', - title: 'search', + id: 'search:next', }); export const searchPlugin = createPlugin({ diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 454c1f02a6..981745c8fe 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -25,8 +25,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/sentry', - title: 'Sentry', + id: 'sentry', }); export const sentryPlugin = createPlugin({ diff --git a/plugins/shortcuts/src/ShortcutItem.tsx b/plugins/shortcuts/src/ShortcutItem.tsx index d2ac716737..8ba1a96358 100644 --- a/plugins/shortcuts/src/ShortcutItem.tsx +++ b/plugins/shortcuts/src/ShortcutItem.tsx @@ -15,7 +15,9 @@ */ import React from 'react'; -import { IconButton, makeStyles } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; import EditIcon from '@material-ui/icons/Edit'; import { ShortcutIcon } from './ShortcutIcon'; import { EditShortcut } from './EditShortcut'; @@ -76,21 +78,23 @@ export const ShortcutItem = ({ shortcut, api }: Props) => { return ( <> - } - > - + } > - - - + + + + + (options: RouterOptions): Promise; +// @public +export const entityMetadataFactRetriever: FactRetriever; + +// @public +export const entityOwnershipFactRetriever: FactRetriever; + // @public export type PersistenceContext = { techInsightsStore: TechInsightsStore; @@ -52,6 +58,9 @@ export interface RouterOptions< persistenceContext: PersistenceContext; } +// @public +export const techdocsFactRetriever: FactRetriever; + // @public (undocumented) export type TechInsightsContext< CheckType extends TechInsightCheck, diff --git a/plugins/tech-insights-backend/src/index.ts b/plugins/tech-insights-backend/src/index.ts index 5e8e690e8d..763c565108 100644 --- a/plugins/tech-insights-backend/src/index.ts +++ b/plugins/tech-insights-backend/src/index.ts @@ -25,3 +25,4 @@ export type { export type { PersistenceContext } from './service/persistence/persistenceContext'; export { createFactRetrieverRegistration } from './service/fact/createFactRetriever'; +export * from './service/fact/factRetrievers'; diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts index 4dc002465b..8d1f7180a3 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts @@ -44,7 +44,7 @@ const testFactRetriever: FactRetriever = { description: '', }, }, - handler: async () => { + handler: jest.fn(async () => { return [ { entity: { @@ -57,7 +57,7 @@ const testFactRetriever: FactRetriever = { }, }, ]; - }, + }), }; const cadence = '1 * * * *'; describe('FactRetrieverEngine', () => { @@ -145,5 +145,8 @@ describe('FactRetrieverEngine', () => { const job: any = engine.getJob('test-factretriever'); job.triggerScheduledJobNow(); expect(job.cadence!!).toEqual(cadence); + expect(testFactRetriever.handler).toHaveBeenCalledWith( + expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }), + ); }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts index aa2207cca8..11fd0d29b8 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts @@ -108,7 +108,10 @@ export class FactRetrieverEngine { this.logger.info( `Retrieving facts for fact retriever ${factRetriever.id}`, ); - const facts = await factRetriever.handler(this.factRetrieverContext); + const facts = await factRetriever.handler({ + ...this.factRetrieverContext, + entityFilter: factRetriever.entityFilter, + }); if (this.logger.isDebugEnabled()) { this.logger.debug( `Retrieved ${facts.length} facts for fact retriever ${ diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts new file mode 100644 index 0000000000..b99f6a1c02 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; +import { entityMetadataFactRetriever } from './entityMetadataFactRetriever'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityMetadataFactRetriever; + +describe('entityMetadataFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasDescription', () => { + describe('where the entity has a description', () => { + it('returns true for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasDescription: true, + }, + }); + }); + }); + describe('where the entity does not have a description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + describe('where the entity has an empty value for description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + }); + describe('hasTags', () => { + describe('where the entity has tags', () => { + it('returns true for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasTags: true, + }, + }); + }); + }); + describe('where the entity has no tags', () => { + it('returns false for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasTags: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts new file mode 100644 index 0000000000..535aede115 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import isEmpty from 'lodash/isEmpty'; + +/** + * Generates facts which indicate the completeness of entity metadata. + * + * @public + */ +export const entityMetadataFactRetriever: FactRetriever = { + id: 'entityMetadataFactRetriever', + version: '0.0.1', + schema: { + hasTitle: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + hasDescription: { + type: 'boolean', + description: 'The entity has a description in metadata', + }, + hasTags: { + type: 'boolean', + description: 'The entity has tags in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasTitle: Boolean(entity.metadata?.title), + hasDescription: Boolean(entity.metadata?.description), + hasTags: !isEmpty(entity.metadata?.tags), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts new file mode 100644 index 0000000000..ea0801f17c --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts @@ -0,0 +1,194 @@ +/* + * 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 { entityOwnershipFactRetriever } from './entityOwnershipFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityOwnershipFactRetriever; + +describe('entityFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasOwner', () => { + describe('where the entity has an owner in the spec', () => { + it('returns true for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasOwner: true, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + + describe('where the entity doesn not have an owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + }); + describe('hasGroupOwner', () => { + describe('where the entity has an group as owner in the spec', () => { + it('returns true for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: true, + }, + }); + }); + }); + describe('where the entity has a user as owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-user-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts new file mode 100644 index 0000000000..8f61f182ad --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; + +/** + * Generates facts which indicate the quality of data in the spec.owner field. + * + * @public + */ +export const entityOwnershipFactRetriever: FactRetriever = { + id: 'entityOwnershipFactRetriever', + version: '0.0.1', + entityFilter: [ + { kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] }, + ], + schema: { + hasOwner: { + type: 'boolean', + description: 'The spec.owner field is set', + }, + hasGroupOwner: { + type: 'boolean', + description: 'The spec.owner field is set and refers to a group', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasOwner: Boolean(entity.spec?.owner), + hasGroupOwner: Boolean( + entity.spec?.owner && + !(entity.spec?.owner as string).startsWith('user:'), + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts new file mode 100644 index 0000000000..587db8c4b9 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ +export * from './entityOwnershipFactRetriever'; +export * from './entityMetadataFactRetriever'; +export * from './techdocsFactRetriever'; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts new file mode 100644 index 0000000000..6ed9b12523 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { techdocsFactRetriever } from './techdocsFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = techdocsFactRetriever; + +describe('techdocsFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasAnnotationBackstageIoTechdocsRef', () => { + describe('where the retriever is configured to check for the techdocs annotation', () => { + describe('where the entity has the techdocs annotation', () => { + it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: true, + }, + }); + }); + }); + describe('where the entity is missing the techdocs annotation', () => { + it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: false, + }, + }); + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts new file mode 100644 index 0000000000..c1c3ba5d20 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts @@ -0,0 +1,65 @@ +/* + * 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 { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { entityHasAnnotation, generateAnnotationFactName } from './utils'; + +const techdocsAnnotation = 'backstage.io/techdocs-ref'; +const techdocsAnnotationFactName = + generateAnnotationFactName(techdocsAnnotation); + +/** + * Generates facts related to the completeness of techdocs configuration for entities. + * + * @public + */ +export const techdocsFactRetriever: FactRetriever = { + id: 'techdocsFactRetriever', + version: '0.0.1', + schema: { + [techdocsAnnotationFactName]: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + [techdocsAnnotationFactName]: entityHasAnnotation( + entity, + techdocsAnnotation, + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts new file mode 100644 index 0000000000..7967502a63 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts @@ -0,0 +1,24 @@ +/* + * 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 camelCase from 'lodash/camelCase'; +import { Entity } from '@backstage/catalog-model'; +import { get } from 'lodash'; + +export const generateAnnotationFactName = (annotation: string) => + camelCase(`hasAnnotation-${annotation}`); + +export const entityHasAnnotation = (entity: Entity, annotation: string) => + Boolean(get(entity, ['metadata', 'annotations', annotation])); diff --git a/plugins/tech-insights-node/api-report.md b/plugins/tech-insights-node/api-report.md index caf4b4cf0e..fc137da4bc 100644 --- a/plugins/tech-insights-node/api-report.md +++ b/plugins/tech-insights-node/api-report.md @@ -53,6 +53,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger_2; + entityFilter?: + | Record[] + | Record; }; // @public diff --git a/plugins/tech-insights-node/src/facts.ts b/plugins/tech-insights-node/src/facts.ts index a52a91f2f4..4a3157d391 100644 --- a/plugins/tech-insights-node/src/facts.ts +++ b/plugins/tech-insights-node/src/facts.ts @@ -136,6 +136,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger; + entityFilter?: + | Record[] + | Record; }; /** diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index bd30d6a984..495aa47879 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -25,7 +25,7 @@ import { } from '@backstage/core-plugin-api'; const rootRouteRef = createRouteRef({ - title: 'Tech Radar', + id: 'tech-radar', }); /** diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 0023a4b757..da3cd34830 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -121,6 +121,14 @@ export interface Config { * @visibility backend */ s3ForcePathStyle?: boolean; + + /** + * (Optional) AWS Server Side Encryption + * Defaults to undefined. + * If not set, encrypted buckets will fail to publish. + * https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-encryption.html + */ + sse?: 'aws:kms' | 'AES256'; }; } | { diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index e84454ae54..77e69995ae 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -39,7 +39,7 @@ export const DocsCardGrid = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; return ( diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index f210e5f678..0e64c2e2e8 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -56,7 +56,7 @@ export const DocsTable = ({ 'techdocs.legacyUseCaseSensitiveTripletPaths', ) ? (str: string) => str - : (str: string) => str.toLocaleLowerCase(); + : (str: string) => str.toLocaleLowerCase('en-US'); if (!entities) return null; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 4e12ac18d1..90a3aa3489 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -76,8 +76,12 @@ const TechDocsReaderContext = createContext( {} as TechDocsReaderValue, ); -const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { - const { namespace = '', kind = '', name = '', '*': path } = useParams(); +const TechDocsReaderProvider = ({ + children, + entityRef, +}: PropsWithChildren<{ entityRef: EntityName }>) => { + const { '*': path } = useParams(); + const { kind, namespace, name } = entityRef; const value = useReaderState(kind, namespace, name, path); return ( @@ -96,10 +100,10 @@ const TechDocsReaderProvider = ({ children }: PropsWithChildren<{}>) => { * @internal */ export const withTechDocsReaderProvider = - (Component: ComponentType) => + (Component: ComponentType, entityRef: EntityName) => (props: T) => ( - + ); @@ -128,12 +132,12 @@ export const useTechDocsReader = () => useContext(TechDocsReaderContext); * todo: Make public or stop exporting (see others: "altReaderExperiments") * @internal */ -export const useTechDocsReaderDom = (): Element | null => { +export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { const navigate = useNavigate(); const theme = useTheme(); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const { namespace = '', kind = '', name = '' } = useParams(); + const { namespace = '', kind = '', name = '' } = entityRef; const { state, path, content: rawPage } = useTechDocsReader(); const [sidebars, setSidebars] = useState(); @@ -214,6 +218,16 @@ export const useTechDocsReaderDom = (): Element | null => { .md-typeset .admonition, .md-typeset details { font-size: 1rem; } + + /* style the checkmarks of the task list */ + .md-typeset .task-list-control .task-list-indicator::before { + background-color: ${theme.palette.action.disabledBackground}; + } + .md-typeset .task-list-control [type="checkbox"]:checked + .task-list-indicator:before { + background-color: ${theme.palette.success.main}; + } + /**/ + @media screen and (max-width: 76.1875em) { .md-nav { background-color: ${theme.palette.background.default}; @@ -293,8 +307,8 @@ export const useTechDocsReaderDom = (): Element | null => { --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); } :host { - --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); - --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); } `, }), @@ -305,9 +319,11 @@ export const useTechDocsReaderDom = (): Element | null => { namespace, scmIntegrationsApi, techdocsStorageApi, + theme.palette.action.disabledBackground, theme.palette.background.default, theme.palette.background.paper, theme.palette.primary.main, + theme.palette.success.main, theme.palette.text.primary, theme.typography.fontFamily, ], @@ -400,7 +416,7 @@ const TheReader = ({ withSearch = true, }: Props) => { const classes = useStyles(); - const dom = useTechDocsReaderDom(); + const dom = useTechDocsReaderDom(entityRef); const shadowDomRef = useRef(null); const onReadyRef = useRef<() => void>(onReady); @@ -440,7 +456,7 @@ export const Reader = ({ onReady = () => {}, withSearch = true, }: Props) => ( - + createEnv('todo')); + // ... + apiRouter.use('/todo', await kafka(todoEnv)); +``` + ## Scanned Files The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location` diff --git a/plugins/todo/README.md b/plugins/todo/README.md index 4165582b65..3e1c370d8a 100644 --- a/plugins/todo/README.md +++ b/plugins/todo/README.md @@ -2,6 +2,77 @@ This plugin lists `// TODO` comments in source code. It currently exports a single component extension for use on entity pages. +## Setup + +1. Run: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-todo +yarn --cwd packages/backend add @backstage/plugin-todo-backend +``` + +2. Add the plugin backend: + +In a new file named `todo.ts` under `backend/src/plugins`: + +```js +import { Router } from 'express'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + createRouter, + TodoReaderService, + TodoScmReader, +} from '@backstage/plugin-todo-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + reader, + config, + discovery, +}: PluginEnvironment): Promise { + const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const todoService = new TodoReaderService({ + todoReader, + catalogClient, + }); + + return await createRouter({ todoService }); +} +``` + +And then add to `packages/backend/src/index.ts`: + +```js +// In packages/backend/src/index.ts +import todo from './plugins/todo'; +// ... +async function main() { + // ... + const todoEnv = useHotMemoize(module, () => createEnv('todo')); + // ... + apiRouter.use('/todo', await kafka(todoEnv)); +``` + +3. Add the plugin as a tab to your service entities: + +```jsx +// In packages/app/src/components/catalog/EntityPage.tsx +import { EntityTodoContent } from '@backstage/plugin-todo'; + +const serviceEntityPage = ( + + {/* other tabs... */} + + + +``` + ## Format The default parser uses [Leasot](https://github.com/pgilad/leasot), which supports a wide range of languages. By default it supports the `TODO` and `FIXME` tags, along with `@` prefix and author reference through with either a `()` suffix or trailing `/`. For more information on how to configure the parser, see `@backstage/plugin-todo-backend`. diff --git a/plugins/todo/src/routes.ts b/plugins/todo/src/routes.ts index 8ab3fb1b94..01d8d9f354 100644 --- a/plugins/todo/src/routes.ts +++ b/plugins/todo/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'todo', + id: 'todo', }); diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index 653e85a541..a801f2e807 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -37,10 +37,11 @@ export const ProviderSettingsItem: ({ // @public (undocumented) export const Router: ({ providerSettings }: Props) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "SettingsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Settings" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const Settings: () => JSX.Element; +export const Settings: (props: SettingsProps) => JSX.Element; // Warning: (ae-missing-release-tag) "UserSettingsAppearanceCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/user-settings/src/components/Settings.tsx b/plugins/user-settings/src/components/Settings.tsx index deb8dddf58..e9b3ae7d0a 100644 --- a/plugins/user-settings/src/components/Settings.tsx +++ b/plugins/user-settings/src/components/Settings.tsx @@ -18,13 +18,14 @@ import React from 'react'; import SettingsIcon from '@material-ui/icons/Settings'; import { settingsRouteRef } from '../plugin'; import { SidebarItem } from '@backstage/core-components'; +import { useRouteRef, IconComponent } from '@backstage/core-plugin-api'; -export const Settings = () => { - return ( - - ); +type SettingsProps = { + icon?: IconComponent; +}; + +export const Settings = (props: SettingsProps) => { + const routePath = useRouteRef(settingsRouteRef); + const Icon = props.icon ? props.icon : SettingsIcon; + return ; }; diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 54f6faceed..01793dc418 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -21,8 +21,7 @@ import { } from '@backstage/core-plugin-api'; export const settingsRouteRef = createRouteRef({ - path: '/settings', - title: 'Settings', + id: 'user-settings', }); export const userSettingsPlugin = createPlugin({ diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx index 53545890a4..a21918e322 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx @@ -22,24 +22,23 @@ import { TabbedLayout, } from '@backstage/core-components'; import { Overview } from '../Overview'; -import { buildsRouteRef, rootRouteRef } from '../../routes'; -import { RouteRef, SubRouteRef } from '@backstage/core-plugin-api'; +import { buildsRouteRef } from '../../routes'; import { BuildsPage } from '../BuildsPage'; export interface TabConfig { - routeRef: RouteRef | SubRouteRef; + path: string; title: string; component: ReactChild; } const TABS: TabConfig[] = [ { - routeRef: rootRouteRef, + path: '/', title: 'Overview', component: , }, { - routeRef: buildsRouteRef, + path: buildsRouteRef.path, title: 'Builds', component: , }, @@ -53,11 +52,7 @@ export const XcmetricsLayout = () => ( {TABS.map(tab => ( - + {tab.component} ))} diff --git a/plugins/xcmetrics/src/routes.ts b/plugins/xcmetrics/src/routes.ts index 212ecbae48..81934c860f 100644 --- a/plugins/xcmetrics/src/routes.ts +++ b/plugins/xcmetrics/src/routes.ts @@ -16,7 +16,7 @@ import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'XCMetrics', + id: 'xcmetrics', }); export const buildsRouteRef = createSubRouteRef({ diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index c8f35508fa..22eae267ee 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -30,8 +30,15 @@ import { ExtractorConfig, CompilerState, ExtractorLogLevel, + ExtractorMessage, } from '@microsoft/api-extractor'; -import { DocNode, IDocNodeContainerParameters } from '@microsoft/tsdoc'; +import { Program } from 'typescript'; +import { + DocNode, + IDocNodeContainerParameters, + TSDocTagSyntaxKind, +} from '@microsoft/tsdoc'; +import { TSDocConfigFile } from '@microsoft/tsdoc-config'; import { ApiPackage, ApiModel } from '@microsoft/api-extractor-model'; import { IMarkdownDocumenterOptions, @@ -42,6 +49,7 @@ import { DocTableRow } from '@microsoft/api-documenter/lib/nodes/DocTableRow'; import { DocHeading } from '@microsoft/api-documenter/lib/nodes/DocHeading'; import { CustomMarkdownEmitter } from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter'; import { IMarkdownEmitterContext } from '@microsoft/api-documenter/lib/markdown/MarkdownEmitter'; +import { AstDeclaration } from '@microsoft/api-extractor/lib/analyzer/AstDeclaration'; const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); @@ -79,11 +87,96 @@ const { ApiReportGenerator, } = require('@microsoft/api-extractor/lib/generators/ApiReportGenerator'); +function patchFileMessageFetcher( + router: any, + transform: (messages: ExtractorMessage[], ast?: AstDeclaration) => void, +) { + const { + fetchAssociatedMessagesForReviewFile, + fetchUnassociatedMessagesForReviewFile, + } = router; + + router.fetchAssociatedMessagesForReviewFile = + function patchedFetchAssociatedMessagesForReviewFile(ast) { + const messages = fetchAssociatedMessagesForReviewFile.call(this, ast); + return transform(messages, ast); + }; + router.fetchUnassociatedMessagesForReviewFile = + function patchedFetchUnassociatedMessagesForReviewFile() { + const messages = fetchUnassociatedMessagesForReviewFile.call(this); + return transform(messages); + }; +} + const originalGenerateReviewFileContent = ApiReportGenerator.generateReviewFileContent; ApiReportGenerator.generateReviewFileContent = - function decoratedGenerateReviewFileContent(...args) { - const content = originalGenerateReviewFileContent.apply(this, args); + function decoratedGenerateReviewFileContent(collector, ...moreArgs) { + const program = collector.program as Program; + + // The purpose of this override is to allow the @ignore tag to be used to ignore warnings + // of the form "Warning: (ae-forgotten-export) The symbol "FooBar" needs to be exported by the entry point index.d.ts" + patchFileMessageFetcher( + collector.messageRouter, + (messages: ExtractorMessage[]) => { + return messages.filter(message => { + if (message.messageId !== 'ae-forgotten-export') { + return true; + } + + // Symbol name has to be extracted from the message :( + // There's frequently no AST for these exports because type literals + // aren't traversed by the generator. + const symbolMatch = message.text.match(/The symbol "([^"]+)"/); + if (!symbolMatch) { + throw new Error( + `Failed to extract symbol name from message "${message.text}"`, + ); + } + const [, symbolName] = symbolMatch; + + const sourceFile = program.getSourceFile(message.sourceFilePath); + if (!sourceFile) { + throw new Error( + `Failed to find source file in program at path "${message.sourceFilePath}"`, + ); + } + + // NOTE: we limit the @internal functionality to only apply to types that are declared + // in the same module as where they're being referenced from. This limitation makes + // the implementation here simpler but could be revisited if needed. + + // The local name of the symbol within the file, rather than the exported name + const localName = (sourceFile as any).identifiers?.get(symbolName); + if (!localName) { + return true; + } + // The local AST node of the export that we're missing + const local = (sourceFile as any).locals?.get(localName); + if (!local) { + return true; + } + + // If any of the TSDoc comments contain a @ignore tag, we ignore this message + const isIgnored = local.declarations.some(declaration => { + const tags = [declaration.jsDoc] + .flat() + .filter(Boolean) + .flatMap((tagNode: any) => tagNode.tags); + + return tags.some(tag => tag?.tagName.text === 'ignore'); + }); + + return !isIgnored; + }); + }, + ); + + const content = originalGenerateReviewFileContent.call( + this, + collector, + ...moreArgs, + ); return prettier.format(content, { ...require('@spotify/prettier-config'), parser: 'markdown', @@ -99,6 +192,7 @@ const SKIPPED_PACKAGES = [ join('packages', 'codemods'), join('packages', 'create-app'), join('packages', 'e2e-test'), + join('packages', 'embedded-techdocs-app'), join('packages', 'storybook'), join('packages', 'techdocs-cli'), ]; @@ -134,6 +228,18 @@ async function findPackageDirs() { return packageDirs; } +async function getTsDocConfig() { + const tsdocConfigFile = await TSDocConfigFile.loadFile( + require.resolve('@microsoft/api-extractor/extends/tsdoc-base.json'), + ); + tsdocConfigFile.addTagDefinition({ + tagName: '@ignore', + syntaxKind: TSDocTagSyntaxKind.ModifierTag, + }); + tsdocConfigFile.setSupportForTag('@ignore', true); + return tsdocConfigFile; +} + function logApiReportInstructions() { console.log(''); console.log( @@ -236,6 +342,7 @@ async function runApiExtraction({ }, configObjectFullPath: projectFolder, packageJsonFullPath: resolvePath(projectFolder, 'package.json'), + tsdocConfigFile: await getTsDocConfig(), }); // The `packageFolder` needs to point to the location within `dist-types` in order for relative diff --git a/scripts/snyk-github-issue-sync.ts b/scripts/snyk-github-issue-sync.ts old mode 100644 new mode 100755 index f1015f8139..da64e2ea82 --- a/scripts/snyk-github-issue-sync.ts +++ b/scripts/snyk-github-issue-sync.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env yarn ts-node --transpile-only /* * Copyright 2021 The Backstage Authors * @@ -33,7 +34,7 @@ const argv = minimist(process.argv.slice(2)); const GH_OWNER = 'backstage'; const GH_REPO = 'backstage'; const SNYK_GH_LABEL = 'snyk-vulnerability'; -const SNYK_ID_REGEX = /\[([A-Z0-9-:]+)]/i; +const SNYK_ID_REGEX = /\[([^\]]+)]/i; const isDryRun = 'dryrun' in argv; diff --git a/scripts/techdocs-cli.js b/scripts/techdocs-cli.js new file mode 100644 index 0000000000..7cc07f15fa --- /dev/null +++ b/scripts/techdocs-cli.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +execSync(`yarn -s workspace @techdocs/cli build`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli link`, { stdio: 'ignore' }); +execSync(`techdocs-cli ${args.join(' ')}`, { stdio: 'inherit' }); +execSync(`yarn workspace @techdocs/cli unlink`, { stdio: 'ignore' }); diff --git a/yarn.lock b/yarn.lock index 20e268355d..debcf79d8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4540,19 +4540,28 @@ resolved "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== -"@microsoft/api-documenter@^7.13.47": - version "7.13.47" - resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.13.47.tgz#0b7726634232b37f76c0e5e8353cdbb5b52d4ece" - integrity sha512-jk78Pf8cKL2WZf6CkKUUtwegdsTA1Jf0MfIzD50qpG7T257HLrqCi1t70ZA85VpRLR8oSeNHMayqNTWkdku9iA== +"@microsoft/api-documenter@^7.13.68": + version "7.13.68" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.13.68.tgz#c1e144764cac0684adefe78fd848d78c3f374681" + integrity sha512-cRjwK1TDyGxFGgCsRG8G0Yi3Z4akvfWgw1pWAxKFbm7ajlQQGZcHPnb+n4lKlSeQ5g/cxc7hcdw54Mvisne9Bg== dependencies: - "@microsoft/api-extractor-model" "7.13.5" + "@microsoft/api-extractor-model" "7.13.16" "@microsoft/tsdoc" "0.13.2" - "@rushstack/node-core-library" "3.40.0" - "@rushstack/ts-command-line" "4.9.0" + "@rushstack/node-core-library" "3.43.2" + "@rushstack/ts-command-line" "4.10.4" colors "~1.2.1" js-yaml "~3.13.1" resolve "~1.17.0" +"@microsoft/api-extractor-model@7.13.16": + version "7.13.16" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.13.16.tgz#1d67541ebbcea32672c5fdd9392dc1579b2fc23a" + integrity sha512-ttdxVXsTWL5dd26W1YNLe3LgDsE0EE273aZlcLe58W0opymBybCYU1Mn+OHQM8BuErrdvdN8LdpWAAbkiOEN/Q== + dependencies: + "@microsoft/tsdoc" "0.13.2" + "@microsoft/tsdoc-config" "~0.15.2" + "@rushstack/node-core-library" "3.43.2" + "@microsoft/api-extractor-model@7.13.5", "@microsoft/api-extractor-model@^7.13.5": version "7.13.5" resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.13.5.tgz#7836a81ba47b9a654062ed0361e4eee69afae51e" @@ -5294,10 +5303,10 @@ react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -"@rollup/plugin-commonjs@^17.1.0": - version "17.1.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" - integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.1" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee" + integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -5367,6 +5376,21 @@ timsort "~0.3.0" z-schema "~3.18.3" +"@rushstack/node-core-library@3.43.2": + version "3.43.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.43.2.tgz#f067371a94fd92ed8f9d9aa8201c5e9e17a19f0f" + integrity sha512-b7AEhSf6CvZgvuDcWMFDeKx2mQSn9AVnMQVyxNxFeHCtLz3gJicqCOlw2GOXM8HKh6PInLdil/NVCDcstwSrIw== + dependencies: + "@types/node" "12.20.24" + colors "~1.2.1" + fs-extra "~7.0.1" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.17.0" + semver "~7.3.0" + timsort "~0.3.0" + z-schema "~3.18.3" + "@rushstack/rig-package@0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.0.tgz#334ad2846797861361b3445d4cc9ae9164b1885c" @@ -5375,6 +5399,16 @@ resolve "~1.17.0" strip-json-comments "~3.1.1" +"@rushstack/ts-command-line@4.10.4": + version "4.10.4" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.4.tgz#05142b74e5cb207d3dd9b935c82f80d7fcb68042" + integrity sha512-4T5ao4UgDb6LmiRj4GumvG3VT/p6RSMgl7TN7S58ifaAGN2GeTNBajFCDdJs9QQP0d/4tA5p0SFzT7Ps5Byirg== + dependencies: + "@types/argparse" "1.0.38" + argparse "~1.0.9" + colors "~1.2.1" + string-argv "~0.3.1" + "@rushstack/ts-command-line@4.9.0": version "4.9.0" resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.9.0.tgz#781ba42cff73cae097b6d5241b6441e7cc2fe6e0" @@ -6680,10 +6714,10 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@trendyol-js/openstack-swift-sdk@^0.0.4": - version "0.0.4" - resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.4.tgz#570c6ab950319156c175ace005b4fb4d9f895d47" - integrity sha512-9YKOjov+V+yzptei6+B9QPuC5pOMTBTg/NQpb1ZbxvlOaYpWU4HHpSH2BkIFYZ8vYyAfzFNG1T2rjpQ2ZQDUtQ== +"@trendyol-js/openstack-swift-sdk@^0.0.5": + version "0.0.5" + resolved "https://registry.npmjs.org/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.5.tgz#65be3c42b8dbafc57f2f2a46c327e2ad51e5a70e" + integrity sha512-KS5nz0cvd35UUyMzhZm+btGV4prtA1KNE7CCMOGBdVxoMGl06Qidli3HgHoc2I9jLPmky1SPp5yzQUwrsyWa0g== dependencies: agentkeepalive "^4.1.4" axios "^0.21.1" @@ -6825,6 +6859,14 @@ dependencies: classnames "*" +"@types/clean-css@*": + version "4.2.5" + resolved "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89" + integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw== + dependencies: + "@types/node" "*" + source-map "^0.6.0" + "@types/codemirror@^0.0.108": version "0.0.108" resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz#e640422b666bf49251b384c390cdeb2362585bde" @@ -6856,6 +6898,13 @@ resolved "https://registry.npmjs.org/@types/command-exists/-/command-exists-1.2.0.tgz#d97e0ed10097090e4ab0367ed425b0312fad86f3" integrity sha512-ugsxEJfsCuqMLSuCD4PIJkp5Uk2z6TCMRCgYVuhRo5cYQY3+1xXTQkSlPtkpGHuvWMjS2KTeVQXxkXRACMbM6A== +"@types/commander@^2.12.2": + version "2.12.2" + resolved "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" + integrity sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q== + dependencies: + commander "*" + "@types/compression@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" @@ -7112,6 +7161,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.6": + version "9.0.13" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -7169,6 +7225,24 @@ resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== +"@types/html-minifier@*": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-4.0.1.tgz#9486ffc144f8d7b8f75b07939c500ac3d73617a0" + integrity sha512-6u58FWQbWP45bsxVeMJo0yk2LEsjjZsCwn0JDe/i5Edk3L+b9TR5eZ2FGaMCrLdtGYpME5AGxUqv8o+3hWKogw== + dependencies: + "@types/clean-css" "*" + "@types/relateurl" "*" + "@types/uglify-js" "*" + +"@types/html-webpack-plugin@*": + version "3.2.6" + resolved "https://registry.npmjs.org/@types/html-webpack-plugin/-/html-webpack-plugin-3.2.6.tgz#07951aaf0fa260dbf626f9644f1d13106d537625" + integrity sha512-U8uJSvlf9lwrKG6sKFnMhqY4qJw2QXad+PHlX9sqEXVUMilVt96aVvFde73tzsdXD+QH9JS6kEytuGO2JcYZog== + dependencies: + "@types/html-minifier" "*" + "@types/tapable" "^1" + "@types/webpack" "^4" + "@types/http-assert@*": version "1.5.1" resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" @@ -7534,6 +7608,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c" integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg== +"@types/node@12.20.24": + version "12.20.24" + resolved "https://registry.npmjs.org/@types/node/-/node-12.20.24.tgz#c37ac69cb2948afb4cef95f424fa0037971a9a5c" + integrity sha512-yxDeaQIAJlMav7fH5AQqPH1u8YIuhYJXYBzxaQ4PifsU0GDO38MSdmEDeRlIxrKbC6NbEaaEHDanWb+y30U8SQ== + "@types/node@^12.7.1": version "12.12.58" resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c" @@ -7692,6 +7771,17 @@ dependencies: "@types/react" "*" +"@types/react-dev-utils@^9.0.4": + version "9.0.8" + resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.8.tgz#7e4d63d1e1c71cd236c9055bc0c0dbaa3772bcf9" + integrity sha512-H/R8BvtCf9BUWPLL9a2agUWWBOKQQPkBIe5osdrgGaDJHZggQRiNN3emH16rQkzm5zi6TVuslOFrYrfMx+QTjw== + dependencies: + "@types/eslint" "*" + "@types/express" "*" + "@types/html-webpack-plugin" "*" + "@types/webpack" "^4" + "@types/webpack-dev-server" "^3" + "@types/react-dom@*", "@types/react-dom@>=16.9.0": version "17.0.9" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -7802,6 +7892,11 @@ resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.2.tgz#a1ad747fbcc6726643a8eb2c42bb804bbf34ce02" integrity sha512-i7KOGl6xdkfpq5+p2ooC+/XFIRUMkYymZ29SD8p+Ko9lesKGUsh6860ey3YM7Y+ZG7kEDGcjzyLO3sOhozqEeA== +"@types/relateurl@*": + version "0.2.29" + resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6" + integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg== + "@types/request@^2.47.1": version "2.48.5" resolved "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" @@ -7863,6 +7958,13 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-7.3.8.tgz#508a27995498d7586dcecd77c25e289bfaf90c59" integrity sha512-D/2EJvAlCEtYFEYmmlGwbGXuK886HzyCc3nZX/tkFTQdEU8jZDAgiv08P162yB17y4ZXZoq7yFAnW4GDBb9Now== +"@types/serve-handler@^6.1.0": + version "6.1.1" + resolved "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.1.tgz#629dc9a62b201ab79a216e1e46e162aa4c8d1455" + integrity sha512-bIwSmD+OV8w0t2e7EWsuQYlGoS1o5aEdVktgkXaa43Zm0qVWi21xaSRb3DQA1UXD+DJ5bRq1Rgu14ZczB+CjIQ== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.9" resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -8057,9 +8159,9 @@ integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== "@types/unzipper@^0.10.3": - version "0.10.3" - resolved "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.3.tgz#9eea872fb1fa460da76f253878b6275af588f464" - integrity sha512-01mQdTLp3/KuBVDhP82FNBf+enzVOjJ9dGsCWa5z8fcYAFVgA9bqIQ2NmsgNFzN/DhD0PUQj4n5p7k6I9mq80g== + version "0.10.4" + resolved "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.4.tgz#db5be3e1f7d37fdfae290024ffe4f46bdcfa47f2" + integrity sha512-mryXpAwwQadmfjKWoR7NXnELZVlU90xTON1v3Pq2AcOmuAPFkPh09E0X8fpbx2zofoR5zmOIxGqmWOhD0qXE7g== dependencies: "@types/node" "*" @@ -8076,6 +8178,17 @@ "@types/expect" "^1.20.4" "@types/node" "*" +"@types/webpack-dev-server@^3": + version "3.11.6" + resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.6.tgz#d8888cfd2f0630203e13d3ed7833a4d11b8a34dc" + integrity sha512-XCph0RiiqFGetukCTC3KVnY1jwLcZ84illFRMbyFzCcWl90B/76ew0tSqF46oBhnLC4obNDG7dMO0JfTN0MgMQ== + dependencies: + "@types/connect-history-api-fallback" "*" + "@types/express" "*" + "@types/serve-static" "*" + "@types/webpack" "^4" + http-proxy-middleware "^1.0.0" + "@types/webpack-dev-server@^3.11.5": version "3.11.5" resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.11.5.tgz#f4a254a3dd0667c8ee4af90d42afdb4ad1d607f3" @@ -8092,6 +8205,11 @@ resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== +"@types/webpack-env@^1.15.3": + version "1.16.3" + resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a" + integrity sha512-9gtOPPkfyNoEqCQgx4qJKkuNm/x0R2hKR7fdl7zvTJyHnIisuE/LfvXOsYWL0o3qq6uiBnKZNNNzi3l0y/X+xw== + "@types/webpack-sources@*": version "0.1.6" resolved "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.6.tgz#3d21dfc2ec0ad0c77758e79362426a9ba7d7cbcb" @@ -10246,15 +10364,15 @@ browserslist@4.14.2: node-releases "^1.1.61" browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.6: - version "4.16.6" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" - integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== + version "4.18.1" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.18.1.tgz#60d3920f25b6860eb917c6c7b185576f4d8b017f" + integrity sha512-8ScCzdpPwR2wQh8IT82CA2VgDwjHyqMovPBZSNH54+tm4Jk2pCuv90gmAdH6J84OCRWi0b4gMe6O6XPXuJnjgQ== dependencies: - caniuse-lite "^1.0.30001219" - colorette "^1.2.2" - electron-to-chromium "^1.3.723" + caniuse-lite "^1.0.30001280" + electron-to-chromium "^1.3.896" escalade "^3.1.1" - node-releases "^1.1.71" + node-releases "^2.0.1" + picocolors "^1.0.0" bser@2.1.1: version "2.1.1" @@ -10629,10 +10747,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001219: - version "1.0.30001230" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz#8135c57459854b2240b57a4a6786044bdc5a9f71" - integrity sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001280: + version "1.0.30001282" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001282.tgz#38c781ee0a90ccfe1fe7fefd00e43f5ffdcb96fd" + integrity sha512-YhF/hG6nqBEllymSIjLtR2iWDDnChvhnVJqp+vloyt2tEHFG1yBR+ac2B/rOw0qOK0m0lEXU2dv4E/sMk5P9Kg== canvas@^2.6.1: version "2.8.0" @@ -11289,6 +11407,11 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== +commander@*: + version "8.3.0" + resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + commander@7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" @@ -11507,6 +11630,11 @@ contains-path@^0.1.0: resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= +content-disposition@0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -13277,10 +13405,10 @@ elastic-builder@^2.16.0: lodash.isstring "^4.0.1" lodash.omit "^4.5.0" -electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.723: - version "1.3.739" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz#f07756aa92cabd5a6eec6f491525a64fe62f98b9" - integrity sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A== +electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.896: + version "1.3.900" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.900.tgz#5be2c5818a2a012c511b4b43e87b6ab7a296d4f5" + integrity sha512-SuXbQD8D4EjsaBaJJxySHbC+zq8JrFfxtb4GIr4E9n1BcROyMcRrJCYQNpJ9N+Wjf5mFp7Wp0OHykd14JNEzzQ== elegant-spinner@^1.0.1: version "1.0.1" @@ -13649,9 +13777,9 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-cypress@^2.10.3: - version "2.11.3" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.3.tgz#54ee4067aa8192aa62810cd35080eb577e191ab7" - integrity sha512-hOoAid+XNFtpvOzZSNWP5LDrQBEJwbZwjib4XJ1KcRYKjeVj0mAmPmucG4Egli4j/aruv+Ow/acacoloWWCl9Q== + version "2.12.1" + resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.12.1.tgz#9aeee700708ca8c058e00cdafe215199918c2632" + integrity sha512-c2W/uPADl5kospNDihgiLc7n87t5XhUbFDoTl6CfVkmG+kDAb5Ux10V9PoLPu9N+r7znpc+iQlcmAqT1A/89HA== dependencies: globals "^11.12.0" @@ -14364,6 +14492,13 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== +fast-url-parser@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" + integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= + dependencies: + punycode "^1.3.2" + fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -14571,6 +14706,15 @@ find-my-way@^2.2.2: safe-regex2 "^2.0.0" semver-store "^0.3.0" +find-process@^1.4.5: + version "1.4.5" + resolved "https://registry.npmjs.org/find-process/-/find-process-1.4.5.tgz#6a0e4c87a32ca927c05cbed7b9078d62ffaac1a4" + integrity sha512-v11rJYYISUWn+s8qZzgGnBvlzRKf3bOtlGFM8H0kw56lGQtOmLuLCzuclA5kehA2j7S5sioOWdI4woT3jDavAw== + dependencies: + chalk "^4.0.0" + commander "^5.1.0" + debug "^4.1.1" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" @@ -18044,9 +18188,9 @@ joycon@^2.2.5: integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== js-base64@^3.6.0: - version "3.6.1" - resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.6.1.tgz#555aae398b74694b4037af1f8a5a6209d170efbe" - integrity sha512-Frdq2+tRRGLQUIQOgsIGSCd1VePCS2fsddTG5dTCqR0JHgltXWfsxnY0gIXPoMeRmdom6Oyq+UMOFg5suduOjQ== + version "3.7.2" + resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745" + integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ== js-cookie@^2.2.1: version "2.2.1" @@ -18266,10 +18410,10 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b" - integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" @@ -20342,6 +20486,18 @@ mime-db@1.49.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.32" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" @@ -20596,6 +20752,11 @@ mock-fs@^5.1.0: resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.0.tgz#a9aebd4e6d74a626f84b86eae8a372bd061754e8" integrity sha512-wXdQ2nIk81TYIGLphUnbXl8akQpjb9ItfZefMcTxZcoe+djMkd5POU8fQdSEErxVAeT4CgDHWveYquys4H6Cmw== +mock-fs@^5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7" + integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -20895,6 +21056,11 @@ node-abi@^2.21.0: dependencies: semver "^5.4.1" +node-abort-controller@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e" + integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw== + 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" @@ -21066,11 +21232,16 @@ node-pre-gyp@^0.11.0: semver "^5.3.0" tar "^4" -node-releases@^1.1.61, node-releases@^1.1.71: +node-releases@^1.1.61: version "1.1.72" resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== +node-releases@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5" + integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== + nodemon@^2.0.2: version "2.0.12" resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.12.tgz#5dae4e162b617b91f1873b3bfea215dd71e144d5" @@ -22227,7 +22398,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.2: +path-is-inside@1.0.2, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -22264,6 +22435,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -22441,12 +22617,12 @@ pgtools@^0.3.0: pg-connection-string "^0.1.3" yargs "^5.0.0" -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: - version "2.2.2" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" - integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.2.3: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: version "2.3.0" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== @@ -23093,9 +23269,9 @@ prettier@^1.19.1: integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== prettier@^2.2.1: - version "2.4.0" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz#85bdfe0f70c3e777cf13a4ffff39713ca6f64cba" - integrity sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ== + version "2.4.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" + integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== prettier@~2.2.1: version "2.2.1" @@ -23428,7 +23604,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -23577,6 +23753,11 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" +range-parser@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= + range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -25428,6 +25609,20 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" +serve-handler@^6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" + integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== + dependencies: + bytes "3.0.0" + content-disposition "0.5.2" + fast-url-parser "1.1.3" + mime-types "2.1.18" + minimatch "3.0.4" + path-is-inside "1.0.2" + path-to-regexp "2.2.1" + range-parser "1.2.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -29435,6 +29630,11 @@ zip-stream@^4.1.0: compress-commons "^4.1.0" readable-stream "^3.6.0" +zod@^3.11.6, zod@^3.9.5: + version "3.11.6" + resolved "https://registry.npmjs.org/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" + integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"