diff --git a/.changeset/afraid-chairs-warn.md b/.changeset/afraid-chairs-warn.md
new file mode 100644
index 0000000000..1ae2a7513e
--- /dev/null
+++ b/.changeset/afraid-chairs-warn.md
@@ -0,0 +1,6 @@
+---
+'@backstage/errors': patch
+---
+
+Deprecate `parseErrorResponse` in favour of `parseErrorResponseBody`. Deprecate `data` field inside `ErrorResponse` in favour of `body`.
+Rename the error name for unknown errors from `unknown` to `error`.
diff --git a/.changeset/angry-countries-cheat.md b/.changeset/angry-countries-cheat.md
deleted file mode 100644
index e97e0ae56c..0000000000
--- a/.changeset/angry-countries-cheat.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/cli': patch
----
-
-Switched to dynamically determining the packages that are unsafe to repack when executing the CLI within the Backstage main repo.
diff --git a/.changeset/backstage-changelog.js b/.changeset/backstage-changelog.js
index ea8ca2300b..22038b348b 100644
--- a/.changeset/backstage-changelog.js
+++ b/.changeset/backstage-changelog.js
@@ -23,13 +23,13 @@ const {
async function getDependencyReleaseLine(changesets, dependenciesUpdated) {
if (dependenciesUpdated.length === 0) return '';
- const updatedDepenenciesList = dependenciesUpdated.map(
+ const updatedDependenciesList = dependenciesUpdated.map(
dependency => ` - ${dependency.name}@${dependency.newVersion}`,
);
// Return one `Updated dependencies` bullet instead of repeating for each changeset; this
// sacrifices the commit shas for brevity.
- return ['- Updated dependencies', ...updatedDepenenciesList].join('\n');
+ return ['- Updated dependencies', ...updatedDependenciesList].join('\n');
}
module.exports = {
diff --git a/.changeset/curly-points-hide.md b/.changeset/curly-points-hide.md
new file mode 100644
index 0000000000..ba513e5915
--- /dev/null
+++ b/.changeset/curly-points-hide.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-tech-insights-backend': patch
+'@backstage/plugin-tech-insights-backend-module-jsonfc': patch
+---
+
+Update README docs to use correct function/parameter names
diff --git a/.changeset/dry-spies-cover.md b/.changeset/dry-spies-cover.md
deleted file mode 100644
index 7006dee5c4..0000000000
--- a/.changeset/dry-spies-cover.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@backstage/core-components': patch
-'@backstage/theme': patch
----
-
-Will Add warning variant to `DismissableBanner` component.
diff --git a/.changeset/early-dragons-wave.md b/.changeset/early-dragons-wave.md
new file mode 100644
index 0000000000..641f36a9f3
--- /dev/null
+++ b/.changeset/early-dragons-wave.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': patch
+---
+
+Bump `react-jsonschema-form`
diff --git a/.changeset/forty-teachers-argue.md b/.changeset/forty-teachers-argue.md
new file mode 100644
index 0000000000..2e32719037
--- /dev/null
+++ b/.changeset/forty-teachers-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-auth-backend': patch
+---
+
+Switched to using the standardized JSON error responses for all provider endpoints.
diff --git a/.changeset/giant-bees-applaud.md b/.changeset/giant-bees-applaud.md
new file mode 100644
index 0000000000..c4591dbc64
--- /dev/null
+++ b/.changeset/giant-bees-applaud.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Switch the default test coverage provider from the jest default one to `'v8'`, which provides much better coverage information when using the default Backstage test setup. This is considered a bug fix as the current coverage information is often very inaccurate.
diff --git a/.changeset/large-mugs-repair.md b/.changeset/large-mugs-repair.md
new file mode 100644
index 0000000000..5043fb80e1
--- /dev/null
+++ b/.changeset/large-mugs-repair.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Disable ES transforms in tests transformed by the `jestSucraseTransform.js`. This is not considered a breaking change since all code is already transpiled this way in the development setup.
diff --git a/.changeset/large-pears-agree.md b/.changeset/large-pears-agree.md
new file mode 100644
index 0000000000..60f755eff0
--- /dev/null
+++ b/.changeset/large-pears-agree.md
@@ -0,0 +1,5 @@
+---
+'@backstage/create-app': patch
+---
+
+DefaultTechDocsCollator is now included in the search backend, and the Search Page updated with the SearchType component that includes the techdocs type
diff --git a/.changeset/long-bugs-kiss.md b/.changeset/long-bugs-kiss.md
deleted file mode 100644
index 49e0edae72..0000000000
--- a/.changeset/long-bugs-kiss.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-kafka': patch
----
-
-Use IdentityApi to provide Auth Token for KafkaBackendClient Api calls
diff --git a/.changeset/loud-bats-flow.md b/.changeset/loud-bats-flow.md
deleted file mode 100644
index af4837dbbe..0000000000
--- a/.changeset/loud-bats-flow.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/test-utils': patch
----
-
-Update Keyboard deprecation with a link to the recommended successor
diff --git a/.changeset/lovely-cars-sneeze.md b/.changeset/lovely-cars-sneeze.md
deleted file mode 100644
index 8e6bfd691c..0000000000
--- a/.changeset/lovely-cars-sneeze.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-scaffolder-backend': patch
----
-
-Expose some classes and interfaces public so TaskWorkers can run externally from the scaffolder API.
diff --git a/.changeset/pretty-trains-appear.md b/.changeset/pretty-trains-appear.md
new file mode 100644
index 0000000000..9a7b313524
--- /dev/null
+++ b/.changeset/pretty-trains-appear.md
@@ -0,0 +1,34 @@
+---
+'@backstage/backend-common': patch
+'@backstage/cli': patch
+'@backstage/core-app-api': patch
+'@backstage/create-app': patch
+'@backstage/techdocs-common': patch
+'@backstage/plugin-auth-backend': patch
+'@backstage/plugin-azure-devops-backend': patch
+'@backstage/plugin-badges-backend': patch
+'@backstage/plugin-bazaar-backend': patch
+'@backstage/plugin-catalog-backend': patch
+'@backstage/plugin-code-coverage-backend': patch
+'@backstage/plugin-github-actions': patch
+'@backstage/plugin-jenkins-backend': patch
+'@backstage/plugin-proxy-backend': patch
+'@backstage/plugin-rollbar-backend': patch
+'@backstage/plugin-search-backend': patch
+'@backstage/plugin-techdocs': patch
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Change default port of backend from 7000 to 7007.
+
+This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start.
+
+You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values:
+
+```
+backend:
+ listen: 0.0.0.0:7123
+ baseUrl: http://localhost:7123
+```
+
+More information can be found here: https://backstage.io/docs/conf/writing
diff --git a/.changeset/purple-grapes-attack.md b/.changeset/purple-grapes-attack.md
new file mode 100644
index 0000000000..a2a99690e9
--- /dev/null
+++ b/.changeset/purple-grapes-attack.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-permission-common': minor
+---
+
+Accept configApi rather than enabled flag in PermissionClient constructor.
diff --git a/.changeset/sharp-carrots-press.md b/.changeset/sharp-carrots-press.md
new file mode 100644
index 0000000000..a7c31d6759
--- /dev/null
+++ b/.changeset/sharp-carrots-press.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-config-schema': patch
+'@backstage/plugin-scaffolder': patch
+---
+
+Fixed a missing `await` when throwing server side errors
diff --git a/.changeset/smart-fans-complain.md b/.changeset/smart-fans-complain.md
new file mode 100644
index 0000000000..db404f110d
--- /dev/null
+++ b/.changeset/smart-fans-complain.md
@@ -0,0 +1,42 @@
+---
+'@backstage/plugin-catalog-backend': minor
+---
+
+**BREAKING** EntitiesSearchFilter fields have changed.
+
+EntitiesSearchFilter now has only two fields: `key` and `value`. The `matchValueIn` and `matchValueExists` fields are no longer are supported. Previous filters written using the `matchValueIn` and `matchValueExists` fields can be rewritten as follows:
+
+Filtering by existence of key only:
+
+```diff
+ filter: {
+ {
+ key: 'abc',
+- matchValueExists: true,
+ },
+ }
+```
+
+Filtering by key and values:
+
+```diff
+ filter: {
+ {
+ key: 'abc',
+- matchValueExists: true,
+- matchValueIn: ['xyz'],
++ values: ['xyz'],
+ },
+ }
+```
+
+Negation of filters can now be achieved through a `not` object:
+
+```
+filter: {
+ not: {
+ key: 'abc',
+ values: ['xyz'],
+ },
+}
+```
diff --git a/.changeset/smooth-vans-boil.md b/.changeset/smooth-vans-boil.md
new file mode 100644
index 0000000000..39b2ea386f
--- /dev/null
+++ b/.changeset/smooth-vans-boil.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Index User entities by displayName to be able to search by full name. Added displayName (if present) to the 'text' field in the indexed document.
diff --git a/.changeset/spicy-panthers-thank.md b/.changeset/spicy-panthers-thank.md
deleted file mode 100644
index 6c28b002f4..0000000000
--- a/.changeset/spicy-panthers-thank.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/core-app-api': patch
----
-
-I have added default icons for the catalog, scaffolder, techdocs, and search.
diff --git a/.changeset/techdocs-buh-bump-ts.md b/.changeset/techdocs-buh-bump-ts.md
deleted file mode 100644
index 438843e04b..0000000000
--- a/.changeset/techdocs-buh-bump-ts.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/techdocs-common': patch
----
-
-Default TechDocs container used at docs generation-time is now [v0.3.5](https://github.com/backstage/techdocs-container/releases/tag/v0.3.5).
diff --git a/.changeset/weak-berries-sing.md b/.changeset/weak-berries-sing.md
new file mode 100644
index 0000000000..1b0cb8d719
--- /dev/null
+++ b/.changeset/weak-berries-sing.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Update the default routes to use id instead of title
diff --git a/.changeset/wet-seas-deliver.md b/.changeset/wet-seas-deliver.md
new file mode 100644
index 0000000000..da5679eb8e
--- /dev/null
+++ b/.changeset/wet-seas-deliver.md
@@ -0,0 +1,63 @@
+---
+'@backstage/core-app-api': patch
+'@backstage/test-utils': patch
+---
+
+The `ApiRegistry` from `@backstage/core-app-api` class has been deprecated and will be removed in a future release. To replace it, we have introduced two new helpers that are exported from `@backstage/test-utils`, namely `TestApiProvider` and `TestApiRegistry`.
+
+These two new helpers are more tailored for writing tests and development setups, as they allow for partial implementations of each of the APIs.
+
+When migrating existing code it is typically best to prefer usage of `TestApiProvider` when possible, so for example the following code:
+
+```tsx
+render(
+
+ {...}
+
+)
+```
+
+Would be migrated to this:
+
+```tsx
+render(
+
+ {...}
+
+)
+```
+
+In cases where the `ApiProvider` is used in a more standalone way, for example to reuse a set of APIs across multiple tests, the `TestApiRegistry` can be used instead. Note that the `TestApiRegistry` only has a single static factory method, `.from()`, and it is slightly different from the existing `.from()` method on `ApiRegistry` in that it doesn't require the API pairs to be wrapped in an outer array.
+
+Usage that looks like this:
+
+```ts
+const apis = ApiRegistry.with(
+ identityApiRef,
+ mockIdentityApi as unknown as IdentityApi,
+).with(configApiRef, new ConfigReader({}));
+```
+
+OR like this:
+
+```ts
+const apis = ApiRegistry.from([
+ [identityApiRef, mockIdentityApi as unknown as IdentityApi],
+ [configApiRef, new ConfigReader({})],
+]);
+```
+
+Would be migrated to this:
+
+```ts
+const apis = TestApiRegistry.from(
+ [identityApiRef, mockIdentityApi],
+ [configApiRef, new ConfigReader({})],
+);
+```
+
+If your app is still using the `ApiRegistry` to construct the `apis` for `createApp`, we recommend that you move over to use the new method of supplying API factories instead, using `createApiFactory`.
diff --git a/.changeset/yellow-pandas-draw.md b/.changeset/yellow-pandas-draw.md
new file mode 100644
index 0000000000..6d4f8116b3
--- /dev/null
+++ b/.changeset/yellow-pandas-draw.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Pin sidebar by default for easier navigation
diff --git a/.changeset/young-sheep-impress.md b/.changeset/young-sheep-impress.md
new file mode 100644
index 0000000000..b69447d4d4
--- /dev/null
+++ b/.changeset/young-sheep-impress.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-graphiql': patch
+---
+
+Letting GraphiQL use headers
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/snyk-github-issue-sync.yml b/.github/workflows/snyk-github-issue-sync.yml
new file mode 100644
index 0000000000..7b93374609
--- /dev/null
+++ b/.github/workflows/snyk-github-issue-sync.yml
@@ -0,0 +1,63 @@
+name: 'Snyk Github Issue Sync'
+
+on:
+ schedule:
+ - cron: '0 */4 * * *'
+
+jobs:
+ sync:
+ if: github.repository == 'backstage/backstage' # prevent running on forks
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [14.x]
+
+ steps:
+ - uses: actions/checkout@v2
+
+ # Beginning of yarn setup, keep in sync between all workflows, see ci.yml
+ - name: use node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ registry-url: https://registry.npmjs.org/ # Needed for auth
+ - name: cache all node_modules
+ id: cache-modules
+ uses: actions/cache@v2
+ with:
+ path: '**/node_modules'
+ key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
+ - name: find location of global yarn cache
+ id: yarn-cache
+ if: steps.cache-modules.outputs.cache-hit != 'true'
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+ - name: cache global yarn cache
+ uses: actions/cache@v2
+ if: steps.cache-modules.outputs.cache-hit != 'true'
+ with:
+ path: ${{ steps.yarn-cache.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+ - name: yarn install
+ run: yarn install --frozen-lockfile
+ # End of yarn setup
+
+ - name: Create Snyk report
+ uses: snyk/actions/node@master
+ continue-on-error: true # Snyk CLI exits with error when vulnerabilities are found
+ with:
+ args: >
+ --yarn-workspaces
+ --org=backstage-dgh
+ --strict-out-of-sync=false
+ --json-file-output=snyk.json
+ json: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+ - name: Update Github issues
+ run: yarn ts-node scripts/snyk-github-issue-sync.ts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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/.tugboat/config.yml b/.tugboat/config.yml
index b8c3c127c8..883708bef7 100644
--- a/.tugboat/config.yml
+++ b/.tugboat/config.yml
@@ -1,7 +1,7 @@
services:
backstage:
image: tugboatqa/node:lts
- expose: 7000
+ expose: 7007
default: true
commands:
init:
@@ -14,4 +14,4 @@ services:
- yarn workspace example-app build
start:
# wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty.
- - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000
+ - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7007
diff --git a/ADOPTERS.md b/ADOPTERS.md
index a2015086c3..3f32a22c23 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -1,64 +1,73 @@
-| Organization | Contact | Description of Use |
-| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. |
-| [bol.com](https://www.bol.com) | [@sagacity](https://github.com/sagacity) | Initial work being done to unify platform tooling. |
-| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
-| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
-| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
-| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
-| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
-| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
-| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
-| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
-| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
-| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
-| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
-| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks |
-| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. |
-| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. |
-| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. |
-| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit |
-| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go |
-| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling |
-| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. |
-| [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. |
-| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. |
-| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. |
-| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a teamโs engineering dependencies. |
-| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. |
-| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. |
-| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. |
-| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. |
-| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. |
-| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐๐๐งโ๐ |
-| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes |
-| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). |
-| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. |
-| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. |
-| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
-| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. |
-| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. |
-| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. |
-| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. |
-| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. |
-| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration |
-| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. |
-| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar |
-| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. |
-| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. |
-| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. |
-| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. |
-| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc |
-| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. |
-| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe |
-| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe |
-| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. |
-| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. |
-| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐. |
-| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. |
-| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. |
-| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. |
-| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. |
-| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. |
-| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation.|
-| [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.|
+| Organization | Contact | Description of Use |
+| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. |
+| [bol.com](https://www.bol.com) | [@sagacity](https://github.com/sagacity) | Initial work being done to unify platform tooling. |
+| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
+| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
+| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
+| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
+| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
+| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
+| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
+| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
+| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
+| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
+| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
+| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks |
+| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. |
+| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. |
+| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. |
+| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit |
+| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go |
+| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling |
+| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. |
+| [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. |
+| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. |
+| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. |
+| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a teamโs engineering dependencies. |
+| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. |
+| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. |
+| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. |
+| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. |
+| [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. |
+| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process ๐๐๐งโ๐ |
+| [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes |
+| [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). |
+| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. |
+| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. |
+| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
+| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. |
+| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. |
+| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. |
+| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. |
+| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. |
+| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration |
+| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. |
+| [2U](https://2u.com) | [Andrew Thal](https://github.com/athal7) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar |
+| [Taxfix](https://taxfix.de/) | [Sami Ur Rehman](https://github.com/samiurrehman92) | Developer's portal with software catalog at it's core. Hosts API Specs, Tech Docs, Tech Radar and some custom plugins. |
+| [Busuu](https://busuu.com/) | [Adam Tester](https://github.com/adamtester) | Developer portal with service catalog, API docs, Event docs, service templating, and cost insights. |
+| [Loadsmart](https://loadsmart.com/) | [Loadsmart](https://github.com/loadsmart) | Improve services visibility and operations for service owners and developers. |
+| [Monzo](https://monzo.com/) | [@WillSewell](https://github.com/WillSewell), [@joechrisellis](https://github.com/joechrisellis) | Developer portal showing metadata and docs for over 2000 microservices. We have built a number of plugins such as a UI for our system to measure [software excellence](https://monzo.com/blog/2021/09/15/how-we-measure-software-excellence), and a UI to show deployment and config change events. |
+| [Vaimo](https://www.vaimo.com) | [@vaimo-magnus](https://github.com/vaimo-magnus) | Developer Portal for our developers at Vaimo, currently docs and self-service towards our internal PaaS based on k8s. Plans to extend the catalog into Projects, Environments etc |
+| [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. |
+| [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe |
+| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe |
+| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. |
+| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. |
+| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems๐. |
+| [GoCardless](https://gocardless.com/) | [James Turley](https://github.com/tragiclifestories) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal systems. |
+| [Box](https://www.box.com) | [@kielosz](https://github.com/kielosz), [@jluk-box](https://github.com/jluk-box), [@ptychu](https://github.com/ptychu), [@alexrybch](https://github.com/alexrybch), [@szubster](https://github.com/szubster) | Developer portal for service catalog, integration with internal systems, new service onboarding. |
+| [Bazaarvoice](https://www.bazaarvoice.com) | [@niallmccullagh](https://github.com/niallmccullagh) | Developer portal for service catalog and scaffolds, publishing Github docs and API documentation, visualising our internal tech radar and our product engineering org structure. |
+| [Krateo PlatformOps](https://www.krateo.io) | [@projectkerberus](https://github.com/projectkerberus) | A multi-cloud control plane to create, manage and deploy any kind of resource easily and centrally via a Developer Portal that centralizes via a self-service catalog the templating and ownership of services, the available documentation, the overview of the components that compose an entire domain and all the data of the service lifecycle. |
+| [Adevinta](https://www.adevinta.com) | [Ray Sinnema](https://github.com/RemonSinnema) | Showcase shared services to our internal customers. |
+| [Splunk](https://www.splunk.com) | [@tonytamsf](https://github.com/tonytamsf) | Developer portal as a centralized place to find people, services, documentation, escalation policies and give bravos. This portal is also being used as a centralized search engine for engineering specific documentation. |
+| [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 |
+| [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! |
+| [Globo](https://globo.com) | [Carlos Gusmรฃo](https://github.com/caeugusmao), [Guilherme Vierno](https://github.com/vierno), [Denis Aoki](https://github.com/dnsaoki2), [Maycon Dionisio](https://github.com/MayconDionisio), | Reduce the friction of accessing the information engineers need about Globo's digital services through a coherent and centralized experience. |
+| [QBE](https://www.qbe.com/) | [Daniel Steel](https://github.com/danielsteelqbe), [Pete Jespers](https://github.com/petejespersqbe) | Developer portal allowing our global teams to explore and create applications, documentation and cloud infrastructure easily and quickly ๐ |
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99d63cf0b4..31f912f43e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -122,7 +122,9 @@ We use [changesets](https://github.com/atlassian/changesets) to help us prepare
Any time a patch, minor, or major change aligning to [Semantic Versioning](https://semver.org) is made to any published package in `packages/` or `plugins/`, a changeset should be used. It helps to align your change to the [Backstage stability index](https://backstage.io/docs/overview/stability-index) for the package you are changing, for example, when to provide additional clarity on deprecation or impacting changes which will then be included into CHANGELOGs.
-In general, changesets are not needed for the documentation, build utilities, contributed samples in `contrib/`, or the [example `packages/app`](packages/app).
+In general, changesets are only needed for changes to packages within `packages/` or `plugins/` directories, and only for the packages that are not marked as `private`. Changesets are also not needed for changes that do not affect the published version of each package, for example changes to tests or in-line source code comments.
+
+Changesets **are** needed for new packages, as that is what triggers the package to be part of the next release.
### How to create a changeset
diff --git a/README.md b/README.md
index 13b7c945cf..1078631926 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,6 @@
# [Backstage](https://backstage.io)
-> ๐ All of the maintainers will be taking a wellness break Nov. 1โ5. The repo and Discord may be quieter than usual, but not to worry. Weโll have coverage plans in place and be back in full force, rested and restored, on Nov. 8. ๐
-
[](https://opensource.org/licenses/Apache-2.0)
[](https://www.cncf.io/projects)
[](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22)
diff --git a/app-config.yaml b/app-config.yaml
index 05d03c26b0..3eab123027 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -23,9 +23,9 @@ app:
title: '#backstage'
backend:
- baseUrl: http://localhost:7000
+ baseUrl: http://localhost:7007
listen:
- port: 7000
+ port: 7007
database:
client: sqlite3
connection: ':memory:'
diff --git a/contrib/chart/backstage/files/app-config.development.yaml.tpl b/contrib/chart/backstage/files/app-config.development.yaml.tpl
index 6d3ded16c1..105ba3b259 100644
--- a/contrib/chart/backstage/files/app-config.development.yaml.tpl
+++ b/contrib/chart/backstage/files/app-config.development.yaml.tpl
@@ -1,7 +1,7 @@
backend:
lighthouseHostname: {{ include "lighthouse.serviceName" . | quote }}
listen:
- port: {{ .Values.appConfig.backend.listen.port | default 7000 }}
+ port: {{ .Values.appConfig.backend.listen.port | default 7007 }}
database:
client: {{ .Values.appConfig.backend.database.client | quote }}
connection:
diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml
index 9f70dac6dc..6ffe076e57 100644
--- a/contrib/chart/backstage/values.yaml
+++ b/contrib/chart/backstage/values.yaml
@@ -26,7 +26,7 @@ backend:
repository: martinaif/backstage-k8s-demo-backend
tag: 20210423T1550
pullPolicy: IfNotPresent
- containerPort: 7000
+ containerPort: 7007
serviceType: ClusterIP
postgresCertMountEnabled: true
resources:
@@ -96,7 +96,7 @@ appConfig:
backend:
baseUrl: https://demo.example.com
listen:
- port: 7000
+ port: 7007
cors:
origin: https://demo.example.com
database:
diff --git a/contrib/docker/devops/makefile b/contrib/docker/devops/makefile
index 3ae89a161b..048edc8886 100644
--- a/contrib/docker/devops/makefile
+++ b/contrib/docker/devops/makefile
@@ -9,8 +9,8 @@ docker_name_prefix := backstage-make
# to the host computer
frontend_port := 3000
frontend_host_port := 3000
-backend_port := 7000
-backend_host_port := 7000
+backend_port := 7007
+backend_host_port := 7007
# path to this "makefile"
my_dir_path = $(dir $(CURDIR)/$(firstword $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)))
@@ -191,10 +191,10 @@ test: check-tests
check: check-code check-docs check-type-dependencies check-styles
# run development instance
-# BUG: the frontend seems to run on "$(backend_port)" (7000 default).
+# BUG: the frontend seems to run on "$(backend_port)" (7007 default).
# The documentation states "This is going to start two things,
-# the frontend (:3000) and the backend (:7000)."
-# However, the frontend seems to end up running on 7000.
+# the frontend (:3000) and the backend (:7007)."
+# However, the frontend seems to end up running on 7007.
.PHONY: dev
dev: build
@docker run --rm -it \
diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml
index f0695753fd..c8d60a9c1b 100644
--- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml
+++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml
@@ -22,6 +22,6 @@ spec:
image: spotify/backstage-backend:latest
imagePullPolicy: IfNotPresent
ports:
- - containerPort: 7000
+ - containerPort: 7007
name: backend
protocol: TCP
diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml
index d91808ed28..a293dc12fb 100644
--- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml
+++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml
@@ -63,7 +63,7 @@ backend:
pullPolicy: IfNotPresent
service:
type: ClusterIP
- port: 7000
+ port: 7007
ingress:
enabled: false
annotations:
diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml
index 4d947b7afc..e1da35b598 100644
--- a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml
+++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml
@@ -30,6 +30,6 @@ spec:
component: backend
ports:
- name: backend
- port: 7000
+ port: 7007
protocol: TCP
targetPort: backend
diff --git a/cypress/cypress.json b/cypress/cypress.json
index 3ef3df8e65..7335d0f62a 100644
--- a/cypress/cypress.json
+++ b/cypress/cypress.json
@@ -1,5 +1,5 @@
{
- "baseUrl": "http://localhost:7000",
+ "baseUrl": "http://localhost:7007",
"integrationFolder": "./src/integration",
"supportFile": "./src/support",
"fixturesFolder": "./src/fixtures",
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/deprecations.md b/docs/api/deprecations.md
new file mode 100644
index 0000000000..aca7e42d96
--- /dev/null
+++ b/docs/api/deprecations.md
@@ -0,0 +1,63 @@
+---
+id: deprecations
+title: Deprecations
+description: A list of active and past deprecations
+---
+
+## Introduction
+
+This page contains extended documentation for some of the deprecations in
+various parts of Backstage. It is not an exhaustive list as most deprecation
+only come in the form of a changelog notice and a console warning. The
+deprecations listed here are the ones that need a bit more guidance than what
+fits in a console message.
+
+### App Theme
+
+`Released 2021-11-12 in @backstage/core-plugin-api v0.1.13`
+
+In order to provide more flexibility in what types of themes can be used and how
+they are applied, the `theme` property on the `AppTheme` type is being
+deprecated and replaced by a `Provider` property instead. The `Provider`
+property is a React component that will be mounted at the root of the app
+whenever that theme is active. This also removes the tight connection to MUI and
+opens up for other type of themes, and removes the hardcoded usage of
+``.
+
+To migrate an existing theme, remove the `theme` property and move it over to a
+new `Provider` component, using `ThemeProvider` from MUI to provide the new
+theme, along with ``. For example a theme that currently looks like
+this:
+
+```tsx
+const darkTheme = {
+ id: 'dark',
+ title: 'Dark Theme',
+ variant: 'dark',
+ icon: ,
+ theme: darkTheme,
+};
+```
+
+Would be migrated to the following:
+
+```tsx
+const darkTheme = {
+ id: 'dark',
+ title: 'Dark Theme',
+ variant: 'dark',
+ icon: ,
+ Provider: ({ children }) => (
+
+ {children}
+
+ ),
+};
+```
+
+Note that the existing `AppTheme` type still requires the `theme` property to be
+set since it's the type that's consumed in the `AppThemeApi`, and it would be a
+breaking change to make `theme` optional. This means that if you currently
+construct the themes that you pass on to `createApp` using `AppTheme` as an
+intermediate type, you will need to work around this in some way, for example by
+passing the themes to `createApp` more directly.
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/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md
index a9a5ef5849..0893c105f4 100644
--- a/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md
+++ b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md
@@ -1,6 +1,6 @@
---
id: adrs-adr012
-title: ADR000: Use Luxon.toLocaleString and date/time presets
+title: ADR012: Use Luxon.toLocaleString and date/time presets
description: Architecture Decision Record (ADR) for using Luxon's toLocaleString method and date/time presets for displaying dates and times
---
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/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md
index 472a6a0abc..07d6869fe3 100644
--- a/docs/auth/add-auth-provider.md
+++ b/docs/auth/add-auth-provider.md
@@ -229,7 +229,7 @@ name.
### Test the new provider
-You can `curl -i localhost:7000/api/auth/providerA/start` and which should
+You can `curl -i localhost:7007/api/auth/providerA/start` and which should
provide a `302` redirect with a `Location` header. Paste the url from that
header into a web browser and you should be able to trigger the authorization
flow.
diff --git a/docs/auth/atlassian/provider.md b/docs/auth/atlassian/provider.md
index 38774ca3cc..cded6e73ca 100644
--- a/docs/auth/atlassian/provider.md
+++ b/docs/auth/atlassian/provider.md
@@ -28,7 +28,7 @@ Name your integration and click on the `Create` button.
Settings for local development:
-- Callback URL: `http://localhost:7000/api/auth/atlassian`
+- Callback URL: `http://localhost:7007/api/auth/atlassian`
- Use rotating refresh tokens
- For permissions, you **must** enable `View user profile` for the currently
logged-in user, under `User identity API`
diff --git a/docs/auth/auth0/provider.md b/docs/auth/auth0/provider.md
index 05553c3fd3..80106a73c9 100644
--- a/docs/auth/auth0/provider.md
+++ b/docs/auth/auth0/provider.md
@@ -17,7 +17,7 @@ provider that can authenticate users using OAuth.
- Application type: Single Page Web Application
4. Click on the Settings tab
5. Add under `Application URIs` > `Allowed Callback URLs`:
- `http://localhost:7000/api/auth/auth0/handler/frame`
+ `http://localhost:7007/api/auth/auth0/handler/frame`
6. Click `Save Changes`
## Configuration
diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md
index ae09d5dbee..63dfb815e0 100644
--- a/docs/auth/bitbucket/provider.md
+++ b/docs/auth/bitbucket/provider.md
@@ -20,7 +20,7 @@ Click Add Consumer.
Settings for local development:
- Application name: Backstage (or your custom app name)
-- Callback URL: `http://localhost:7000/api/auth/bitbucket`
+- Callback URL: `http://localhost:7007/api/auth/bitbucket`
- Other are optional
- (IMPORTANT) **Permissions: Account - Read, Workspace membership - Read**
diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md
index d8803e392d..14b99bfea5 100644
--- a/docs/auth/github/provider.md
+++ b/docs/auth/github/provider.md
@@ -24,7 +24,7 @@ Settings for local development:
- Application name: Backstage (or your custom app name)
- Homepage URL: `http://localhost:3000`
-- Authorization callback URL: `http://localhost:7000/api/auth/github`
+- Authorization callback URL: `http://localhost:7007/api/auth/github`
## Configuration
diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md
index fd64ddac14..90939a1f03 100644
--- a/docs/auth/gitlab/provider.md
+++ b/docs/auth/gitlab/provider.md
@@ -17,7 +17,7 @@ should point to your Backstage backend auth handler.
Settings for local development:
- Name: Backstage (or your custom app name)
-- Redirect URI: `http://localhost:7000/api/auth/gitlab/handler/frame`
+- Redirect URI: `http://localhost:7007/api/auth/gitlab/handler/frame`
- Scopes: read_user
## Configuration
diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md
index 6216b21704..d3cd8f2dd2 100644
--- a/docs/auth/google/provider.md
+++ b/docs/auth/google/provider.md
@@ -26,7 +26,7 @@ To support Google authentication, you must create OAuth credentials:
- `Name`: Backstage (or your custom app name)
- `Authorized JavaScript origins`: http://localhost:3000
- `Authorized Redirect URIs`:
- http://localhost:7000/api/auth/google/handler/frame
+ http://localhost:7007/api/auth/google/handler/frame
7. Click Create
## Configuration
diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md
index 1b81ff76f1..1e24235f1a 100644
--- a/docs/auth/microsoft/provider.md
+++ b/docs/auth/microsoft/provider.md
@@ -21,7 +21,7 @@ To support Azure authentication, you must create an App Registration:
4. Register an application
- Name: Backstage (or your custom app name)
- Redirect URI: Web >
- `http://localhost:7000/api/auth/microsoft/handler/frame`
+ `http://localhost:7007/api/auth/microsoft/handler/frame`
5. Navigate to **Certificates & secrets > New client secret** to create a secret
## Configuration
diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md
index b5aaabe4f1..35394094f8 100644
--- a/docs/auth/okta/provider.md
+++ b/docs/auth/okta/provider.md
@@ -22,8 +22,8 @@ To add Okta authentication, you must create an Application from Okta:
- `App integration name`: `Backstage` (or your custom app name)
- `Grant type`: `Authorization Code` & `Refresh Token`
- `Sign-in redirect URIs`:
- `http://localhost:7000/api/auth/okta/handler/frame`
- - `Sign-out redirect URIs`: `http://localhost:7000`
+ `http://localhost:7007/api/auth/okta/handler/frame`
+ - `Sign-out redirect URIs`: `http://localhost:7007`
- `Controlled access`: (select as appropriate)
- Click Save
diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md
index a11304ae84..c3572367d2 100644
--- a/docs/auth/onelogin/provider.md
+++ b/docs/auth/onelogin/provider.md
@@ -18,7 +18,7 @@ To support OneLogin authentication, you must create an Application:
3. Click Save
4. Go to the Configuration tab for the Application and set:
- `Login Url`: `http://localhost:3000`
- - `Redirect URIs`: `http://localhost:7000/api/auth/onelogin/handler/frame`
+ - `Redirect URIs`: `http://localhost:7007/api/auth/onelogin/handler/frame`
5. Click Save
6. Go to the SSO tab for the Application and set:
- `Token Endpoint` > `Authentication Method`: `POST`
diff --git a/docs/conf/writing.md b/docs/conf/writing.md
index 4288d23b17..7945d6c980 100644
--- a/docs/conf/writing.md
+++ b/docs/conf/writing.md
@@ -16,8 +16,8 @@ app:
baseUrl: http://localhost:3000
backend:
- listen: 0.0.0.0:7000
- baseUrl: http://localhost:7000
+ listen: 0.0.0.0:7007
+ baseUrl: http://localhost:7007
organization:
name: CNCF
@@ -67,13 +67,15 @@ production build.
## Configuration Files
-It is possible to have multiple configuration files, both to support different
-environments, but also to define configuration that is local to specific
-packages. The configuration files to load are selected using a `--config `
-flag, and it is possible to load any number of files. Paths are relative to the
-working directory of the executed process, for example `package/backend`. This
-means that to select a config file in the repo root when running the backend,
-you would use `--config ../../my-config.yaml`.
+It is possible to have multiple configuration files (bundled and/or remote),
+both to support different environments, but also to define configuration that is
+local to specific packages. The configuration files to load are selected using a
+`--config ` flag, and it is possible to load any number of
+files. Paths are relative to the working directory of the executed process, for
+example `package/backend`. This means that to select a config file in the repo
+root when running the backend, you would use `--config ../../my-config.yaml`,
+and for config file on a config server you would use
+`--config https://some.domain.io/app-config.yaml`
If no `config` flags are specified, the default behavior is to load
`app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root.
@@ -85,7 +87,7 @@ are NOT loaded. To include them you need to explicitly include them with a flag,
for example:
```shell
-yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml
+yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml --config https://some.domain.io/app-config.yaml
```
All loaded configuration files are merged together using the following rules:
diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md
index 7230f5cf16..7cbd3cea89 100644
--- a/docs/deployment/docker.md
+++ b/docs/deployment/docker.md
@@ -105,11 +105,11 @@ docker image build . -f packages/backend/Dockerfile --tag backstage
To try out the image locally you can run the following:
```sh
-docker run -it -p 7000:7000 backstage
+docker run -it -p 7007:7007 backstage
```
You should then start to get logs in your terminal, and then you can open your
-browser at `http://localhost:7000`
+browser at `http://localhost:7007`
## Multi-stage Build
@@ -208,11 +208,11 @@ docker image build -t backstage .
To try out the image locally you can run the following:
```sh
-docker run -it -p 7000:7000 backstage
+docker run -it -p 7007:7007 backstage
```
You should then start to get logs in your terminal, and then you can open your
-browser at `http://localhost:7000`
+browser at `http://localhost:7007`
## Separate Frontend
diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md
index 2c52e0e308..a80ba1816e 100644
--- a/docs/deployment/k8s.md
+++ b/docs/deployment/k8s.md
@@ -351,7 +351,7 @@ spec:
imagePullPolicy: IfNotPresent
ports:
- name: http
- containerPort: 7000
+ containerPort: 7007
envFrom:
- secretRef:
name: postgres-secrets
@@ -361,11 +361,11 @@ spec:
# https://backstage.io/docs/plugins/observability#health-checks
# readinessProbe:
# httpGet:
-# port: 7000
+# port: 7007
# path: /healthcheck
# livenessProbe:
# httpGet:
-# port: 7000
+# port: 7007
# path: /healthcheck
```
@@ -449,7 +449,7 @@ spec:
```
The `selector` here is telling the Service which pods to target, and the port
-mapping translates normal HTTP port 80 to the backend http port (7000) on the
+mapping translates normal HTTP port 80 to the backend http port (7007) on the
pod.
Apply this Service to the Kubernetes cluster:
@@ -464,10 +464,10 @@ reveal**_, you can forward a local port to the service:
```shell
$ sudo kubectl port-forward --namespace=backstage svc/backstage 80:80
-Forwarding from 127.0.0.1:80 -> 7000
+Forwarding from 127.0.0.1:80 -> 7007
```
-This shows port 7000 since `port-forward` doesn't _really_ support services, so
+This shows port 7007 since `port-forward` doesn't _really_ support services, so
it cheats by looking up the first pod for a service and connecting to the mapped
pod port.
@@ -486,7 +486,7 @@ organization:
backend:
baseUrl: http://localhost
listen:
- port: 7000
+ port: 7007
cors:
origin: http://localhost
```
diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md
index aa40c1e539..27219e529a 100644
--- a/docs/features/search/getting-started.md
+++ b/docs/features/search/getting-started.md
@@ -109,6 +109,25 @@ const routes = (
);
```
+### Using the Search Modal
+
+In `Root.tsx`, add the `SidebarSearchModal` component:
+
+```bash
+import { SidebarSearchModal } from '@backstage/plugin-search';
+
+export const Root = ({ children }: PropsWithChildren<{}>) => (
+
+
+
+
+
+...
+```
+
+For more information about using `Root.tsx`, please see
+[the changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#0315).
+
## Adding Search to the Backend
Add the following plugins into your backend app:
diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md
index 8f855776a0..e80e8a1842 100644
--- a/docs/features/software-catalog/catalog-customization.md
+++ b/docs/features/software-catalog/catalog-customization.md
@@ -172,6 +172,7 @@ This page itself can be exported as a routable extension in the plugin:
```ts
export const CustomCatalogIndexPage = myPlugin.provide(
createRoutableExtension({
+ name: 'CustomCatalogIndexPage',
component: () =>
import('./components/CustomCatalogPage').then(m => m.CustomCatalogPage),
mountPoint: catalogRouteRef,
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/migrating-from-v1beta2-to-v1beta3.md b/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md
index 280eab083d..4cc9a6a8e6 100644
--- a/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md
+++ b/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3.md
@@ -125,8 +125,8 @@ input schema.
input:
- address: '{{ json parameters.address }}'
+ address: ${{ parameters.address }}
-- number: '{{ parameters.number }}'
-+ number: ${{ parameters.number }} # this will now make sure that the type of number is a number ๐
+- test: '{{ parameters.test }}'
++ test: ${{ parameters.test }} # this will now make sure that the type of test is a number ๐
```
## `parseRepoUrl` is now a `filter`
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
+```
+
+
+
+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..4148749a62 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:
@@ -132,11 +138,11 @@ techdocs:
# (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc.
# You don't have to specify this anymore.
- requestUrl: http://localhost:7000/api/techdocs
+ requestUrl: http://localhost:7007/api/techdocs
# (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware
# to serve files from either a local directory or an External storage provider.
# You don't have to specify this anymore.
- storageUrl: http://localhost:7000/api/techdocs/static/docs
+ storageUrl: http://localhost:7007/api/techdocs/static/docs
```
diff --git a/docs/getting-started/contributors.md b/docs/getting-started/contributors.md
index 28b062b5ca..9154eae4d1 100644
--- a/docs/getting-started/contributors.md
+++ b/docs/getting-started/contributors.md
@@ -43,7 +43,7 @@ the project root. Make sure you have run the above mentioned commands first.
$ yarn dev
```
-This is going to start two things, the frontend (:3000) and the backend (:7000).
+This is going to start two things, the frontend (:3000) and the backend (:7007).
This should open a local instance of Backstage in your browser, otherwise open
one of the URLs printed in the terminal.
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 9632be71bf..5a970ef143 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -37,7 +37,7 @@ guide to do a repository-based installation.
- `docker` [installation](https://docs.docker.com/engine/install/)
- `git` [installation](https://github.com/git-guides/install-git)
- If the system is not directly accessible over your network, the following
- ports need to be opened: 3000, 7000
+ ports need to be opened: 3000, 7007
### Create your Backstage App
diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md
index 873c26a099..daad7bf195 100644
--- a/docs/getting-started/running-backstage-locally.md
+++ b/docs/getting-started/running-backstage-locally.md
@@ -70,7 +70,7 @@ cd packages/backend
yarn start
```
-That starts up a backend instance on port 7000.
+That starts up a backend instance on port 7007.
In the other window, we will then launch the frontend. This command is run from
the project root, not inside the backend directory.
diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md
index 0397ffa09c..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`
@@ -512,7 +551,8 @@ Usage: backstage-cli config:check [options]
Options:
--package <name> Only load config schema that applies to the given package
- --lax Do not require environment variables to be set
+ --lax Do not require environment variables to be set
+ --frontend Only validate the frontend configuration
--config <path> Config files to load instead of app-config.yaml (default: [])
-h, --help display help for command
```
diff --git a/docs/openapi/definitions/auth.yaml b/docs/openapi/definitions/auth.yaml
index 411ce69253..032d89523d 100644
--- a/docs/openapi/definitions/auth.yaml
+++ b/docs/openapi/definitions/auth.yaml
@@ -21,7 +21,7 @@ externalDocs:
description: Backstage official documentation
url: https://github.com/backstage/backstage/blob/master/docs/README.md
servers:
- - url: http://localhost:7000/api/auth/
+ - url: http://localhost:7007/api/auth/
tags:
- name: provider
description: List of endpoints per provider
diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md
index 7adf2650df..ace9a89968 100644
--- a/docs/plugins/analytics.md
+++ b/docs/plugins/analytics.md
@@ -261,10 +261,13 @@ analytics events captured.
Use it like this:
```tsx
-import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
-import { analyticsApiRef } from '@backstage/core-plugin-api';
-import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils';
import { render, fireEvent, waitFor } from '@testing-library/react';
+import { analyticsApiRef } from '@backstage/core-plugin-api';
+import {
+ MockAnalyticsApi,
+ TestApiProvider,
+ wrapInTestApp,
+} from '@backstage/test-utils';
describe('SomeComponent', () => {
it('should capture event on click', () => {
@@ -274,9 +277,9 @@ describe('SomeComponent', () => {
// Render the component being tested
const { getByText } = render(
wrapInTestApp(
-
+
- ,
+ ,
),
);
diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md
index 1d728b76aa..5ff0e5a62f 100644
--- a/docs/plugins/backend-plugin.md
+++ b/docs/plugins/backend-plugin.md
@@ -44,11 +44,11 @@ cd plugins/carmen-backend
yarn start
```
-This will think for a bit, and then say `Listening on :7000`. In a different
+This will think for a bit, and then say `Listening on :7007`. In a different
terminal window, now run
```sh
-curl localhost:7000/carmen/health
+curl localhost:7007/carmen/health
```
This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it
@@ -107,7 +107,7 @@ root), you should be able to fetch data from it.
```sh
# Note the extra /api here
-curl localhost:7000/api/carmen/health
+curl localhost:7007/api/carmen/health
```
This should return `{"status":"ok"}` like before. Success!
diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md
index 8d22c05a79..33568ac561 100644
--- a/docs/plugins/composability.md
+++ b/docs/plugins/composability.md
@@ -136,6 +136,7 @@ a component:
```ts
export const FooPage = plugin.provide(
createRoutableExtension({
+ name: 'FooPage',
component: () => import('./components/FooPage').then(m => m.FooPage),
mountPoint: fooPageRouteRef,
}),
@@ -417,6 +418,7 @@ export const myPlugin = createPlugin({
export const MyPage = myPlugin.provide(
createRoutableExtension({
+ name: 'MyPage',
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRouteRef,
}),
diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md
index f61fae0968..362ecc5d5a 100644
--- a/docs/plugins/plugin-development.md
+++ b/docs/plugins/plugin-development.md
@@ -65,6 +65,7 @@ export const examplePlugin = createPlugin({
// Each extension should also be exported from your plugin package.
export const ExamplePage = examplePlugin.provide(
createRoutableExtension({
+ name: 'ExamplePage',
// The component needs to be lazy-loaded. It's what will actually be rendered in the end.
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md
index 83fcfbddba..9e8b67710a 100644
--- a/docs/plugins/structure-of-a-plugin.md
+++ b/docs/plugins/structure-of-a-plugin.md
@@ -73,6 +73,7 @@ export const examplePlugin = createPlugin({
export const ExamplePage = examplePlugin.provide(
createRoutableExtension({
+ name: 'ExamplePage',
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootRouteRef,
diff --git a/lerna.json b/lerna.json
index 322929db1d..4621689664 100644
--- a/lerna.json
+++ b/lerna.json
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
- "version": "0.1.0"
+ "version": "0.0.0"
}
diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md
index 520a2a5f10..2df8ce84f3 100644
--- a/microsite/blog/2020-05-22-phase-2-service-catalog.md
+++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md
@@ -1,5 +1,5 @@
---
-title: Starting Phase 2: The Service Catalog
+title: 'Starting Phase 2: The Service Catalog'
author: Stefan ร
lund, Spotify
authorURL: http://twitter.com/stalund
authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg
diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md
index 98fbad57c5..f9d7359526 100644
--- a/microsite/blog/2020-09-08-announcing-tech-docs.md
+++ b/microsite/blog/2020-09-08-announcing-tech-docs.md
@@ -1,5 +1,5 @@
---
-title: Announcing TechDocs: Spotifyโs docs-like-code plugin for Backstage
+title: 'Announcing TechDocs: Spotifyโs docs-like-code plugin for Backstage'
author: Gary Niemen, Spotify
authorURL: https://github.com/garyniemen
---
diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md
index d265698694..139e9a7ce9 100644
--- a/microsite/blog/2020-10-22-cost-insights-plugin.md
+++ b/microsite/blog/2020-10-22-cost-insights-plugin.md
@@ -1,5 +1,5 @@
---
-title: New Cost Insights plugin: The engineerโs solution to taming cloud costs
+title: 'New Cost Insights plugin: The engineerโs solution to taming cloud costs'
author: Janisa Anandamohan, Spotify
authorURL: https://twitter.com/janisa_a
---
diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md
index a0d11a580f..9b8c4637bd 100644
--- a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md
+++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md
@@ -1,5 +1,5 @@
---
-title: New Backstage feature: Kubernetes for Service owners
+title: 'New Backstage feature: Kubernetes for Service owners'
author: Matthew Clarke, Spotify
authorURL: https://github.com/mclarke47
---
diff --git a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md
index 0a4ae572c2..6e6c9b7819 100644
--- a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md
+++ b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md
@@ -1,5 +1,5 @@
---
-title: Announcing the Backstage Search platform: a customizable search tool built just for you
+title: 'Announcing the Backstage Search platform: a customizable search tool built just for you'
author: Emma Indal, Spotify
authorURL: https://www.linkedin.com/in/emma-indal
---
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 bee9cd7180..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"
@@ -151,7 +152,10 @@
{
"type": "subcategory",
"label": "GitLab",
- "ids": ["integrations/gitlab/locations"]
+ "ids": [
+ "integrations/gitlab/locations",
+ "integrations/gitlab/discovery"
+ ]
},
{
"type": "subcategory",
@@ -253,7 +257,8 @@
"type": "subcategory",
"label": "API Reference",
"ids": ["reference/index"]
- }
+ },
+ "api/deprecations"
],
"Tutorials": [
"tutorials/journey",
@@ -275,7 +280,8 @@
"architecture-decisions/adrs-adr008",
"architecture-decisions/adrs-adr009",
"architecture-decisions/adrs-adr010",
- "architecture-decisions/adrs-adr011"
+ "architecture-decisions/adrs-adr011",
+ "architecture-decisions/adrs-adr012"
],
"FAQ": ["FAQ"]
}
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 2f4aa4203f..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'
@@ -100,6 +100,7 @@ nav:
- Org Data: 'integrations/github/org.md'
- GitLab:
- Locations: 'integrations/gitlab/locations.md'
+ - Discovery: 'integrations/gitlab/discovery.md'
- Google GCS:
- Locations: 'integrations/google-cloud-storage/locations.md'
- LDAP:
@@ -163,6 +164,7 @@ nav:
- API Reference:
- Guides:
- Utility APIs: 'api/utility-apis.md'
+ - Deprecations: 'api/deprecations.md'
- Tutorials:
- Future developer journey: 'tutorials/journey.md'
- Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md'
diff --git a/package.json b/package.json
index f5d20fd39d..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"
},
@@ -46,30 +49,32 @@
"resolutions": {
"**/@graphql-codegen/cli/**/ws": "^7.4.6",
"**/@roadiehq/**/@backstage/plugin-catalog": "*",
- "**/@roadiehq/**/@backstage/catalog-model": "*",
- "graphql-language-service-interface": "2.8.2",
- "graphql-language-service-parser": "1.9.0"
+ "**/@roadiehq/**/@backstage/catalog-model": "*"
},
"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"
},
"devDependencies": {
- "@types/webpack": "^5.28.0",
"@changesets/cli": "^2.14.0",
+ "@octokit/rest": "^18.12.0",
"@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",
"husky": "^6.0.0",
"lerna": "^4.0.0",
"lint-staged": "^11.1.2",
+ "minimist": "^1.2.5",
"prettier": "^2.2.1",
"shx": "^0.3.2",
+ "ts-node": "^10.4.0",
"yarn-lock-check": "^1.0.5"
},
"prettier": "@spotify/prettier-config",
diff --git a/packages/app-defaults/.eslintrc.js b/packages/app-defaults/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/packages/app-defaults/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/packages/app-defaults/CHANGELOG.md b/packages/app-defaults/CHANGELOG.md
new file mode 100644
index 0000000000..b2b50ae48b
--- /dev/null
+++ b/packages/app-defaults/CHANGELOG.md
@@ -0,0 +1,10 @@
+# @backstage/app-defaults
+
+## 0.1.1
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/core-components@0.7.4
+ - @backstage/core-plugin-api@0.2.0
+ - @backstage/core-app-api@0.1.21
diff --git a/packages/app-defaults/README.md b/packages/app-defaults/README.md
new file mode 100644
index 0000000000..15a0bd3d24
--- /dev/null
+++ b/packages/app-defaults/README.md
@@ -0,0 +1,17 @@
+# @backstage/app-defaults
+
+This package provides a default wiring of a Backstage app that avoids boilerplate when setting up a standard Backstage app.
+
+## Installation
+
+Install the package via Yarn:
+
+```sh
+cd packages/app
+yarn add @backstage/app-defaults
+```
+
+## Documentation
+
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://backstage.io/docs)
diff --git a/packages/app-defaults/api-report.md b/packages/app-defaults/api-report.md
new file mode 100644
index 0000000000..57ea61ad8b
--- /dev/null
+++ b/packages/app-defaults/api-report.md
@@ -0,0 +1,26 @@
+## API Report File for "@backstage/app-defaults"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { AppComponents } from '@backstage/core-app-api';
+import { AppIcons } from '@backstage/core-app-api';
+import { AppOptions } from '@backstage/core-app-api';
+import { AppTheme } from '@backstage/core-plugin-api';
+import { BackstageApp } from '@backstage/core-app-api';
+import { IconComponent } from '@backstage/core-plugin-api';
+
+// @public
+export function createApp(
+ options?: Omit & OptionalAppOptions,
+): BackstageApp;
+
+// @public
+export type OptionalAppOptions = {
+ icons?: Partial & {
+ [key in string]: IconComponent;
+ };
+ themes?: (Partial & Omit)[];
+ components?: Partial;
+};
+```
diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json
new file mode 100644
index 0000000000..7388a44ce0
--- /dev/null
+++ b/packages/app-defaults/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@backstage/app-defaults",
+ "description": "Provides the default wiring of a Backstage App",
+ "version": "0.1.1",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "packages/app-defaults"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "license": "Apache-2.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "backstage-cli build --outputs types,esm",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/core-components": "^0.7.4",
+ "@backstage/core-app-api": "^0.1.21",
+ "@backstage/core-plugin-api": "^0.2.0",
+ "@backstage/theme": "^0.2.13",
+ "@material-ui/core": "^4.12.2",
+ "@material-ui/icons": "^4.9.1",
+ "react": "^16.12.0",
+ "react-router-dom": "6.0.0-beta.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.9.0",
+ "@backstage/test-utils": "^0.1.22",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^11.2.5",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^14.14.32",
+ "@types/react": "*"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/app-defaults/src/createApp.test.tsx b/packages/app-defaults/src/createApp.test.tsx
new file mode 100644
index 0000000000..45988a4840
--- /dev/null
+++ b/packages/app-defaults/src/createApp.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 { screen } from '@testing-library/react';
+import { renderWithEffects } from '@backstage/test-utils';
+import React, { PropsWithChildren } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { createApp } from './createApp';
+
+describe('Optional ThemeProvider', () => {
+ it('should render app with user-provided ThemeProvider', async () => {
+ const components = {
+ NotFoundErrorPage: () => null,
+ BootErrorPage: () => null,
+ Progress: () => null,
+ Router: MemoryRouter,
+ ErrorBoundaryFallback: () => null,
+ ThemeProvider: ({ children }: PropsWithChildren<{}>) => (
+ {children}
+ ),
+ };
+
+ const App = createApp({ components }).getProvider();
+
+ await renderWithEffects();
+
+ expect(screen.getByRole('main')).toBeInTheDocument();
+ });
+});
diff --git a/packages/app-defaults/src/createApp.tsx b/packages/app-defaults/src/createApp.tsx
new file mode 100644
index 0000000000..d85dfa04eb
--- /dev/null
+++ b/packages/app-defaults/src/createApp.tsx
@@ -0,0 +1,93 @@
+/*
+ * 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 { apis, components, icons, themes } from './defaults';
+import {
+ AppTheme,
+ BackstagePlugin,
+ IconComponent,
+} from '@backstage/core-plugin-api';
+import {
+ AppComponents,
+ AppOptions,
+ AppIcons,
+ createSpecializedApp,
+} from '@backstage/core-app-api';
+
+/**
+ * Creates a new Backstage App using a default set of components, icons and themes unless
+ * they are explicitly provided.
+ *
+ * @public
+ */
+export function createApp(
+ options?: Omit & OptionalAppOptions,
+) {
+ return createSpecializedApp({
+ ...options,
+ apis: options?.apis ?? [],
+ bindRoutes: options?.bindRoutes,
+ components: {
+ ...components,
+ ...options?.components,
+ },
+ configLoader: options?.configLoader,
+ defaultApis: apis,
+ icons: {
+ ...icons,
+ ...options?.icons,
+ },
+ plugins: (options?.plugins as BackstagePlugin[]) ?? [],
+ themes: options?.themes ?? themes,
+ });
+}
+
+/**
+ * The set of app options that {@link createApp} will provide defaults for
+ * if they are not passed in explicitly.
+ *
+ * @public
+ */
+export type OptionalAppOptions = {
+ /**
+ * A set of icons to override the default icons with.
+ *
+ * The override is applied for each icon individually.
+ *
+ * @public
+ */
+ icons?: Partial & {
+ [key in string]: IconComponent;
+ };
+
+ /**
+ * A set of themes that override all of the default app themes.
+ *
+ * If this option is provided none of the default themes will be used.
+ *
+ * @public
+ */
+ themes?: (Partial & Omit)[]; // TODO: simplify once AppTheme is updated
+
+ /**
+ * A set of components to override the default components with.
+ *
+ * The override is applied for each icon individually.
+ *
+ * @public
+ */
+ components?: Partial;
+};
diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts
new file mode 100644
index 0000000000..d3c76d52bd
--- /dev/null
+++ b/packages/app-defaults/src/defaults/apis.ts
@@ -0,0 +1,274 @@
+/*
+ * 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 {
+ AlertApiForwarder,
+ NoOpAnalyticsApi,
+ ErrorApiForwarder,
+ ErrorAlerter,
+ GoogleAuth,
+ GithubAuth,
+ OAuth2,
+ OktaAuth,
+ GitlabAuth,
+ Auth0Auth,
+ MicrosoftAuth,
+ BitbucketAuth,
+ OAuthRequestManager,
+ WebStorage,
+ UrlPatternDiscovery,
+ SamlAuth,
+ OneLoginAuth,
+ UnhandledErrorForwarder,
+ AtlassianAuth,
+} from '@backstage/core-app-api';
+
+import {
+ createApiFactory,
+ alertApiRef,
+ analyticsApiRef,
+ errorApiRef,
+ discoveryApiRef,
+ oauthRequestApiRef,
+ googleAuthApiRef,
+ githubAuthApiRef,
+ oauth2ApiRef,
+ oktaAuthApiRef,
+ gitlabAuthApiRef,
+ auth0AuthApiRef,
+ microsoftAuthApiRef,
+ storageApiRef,
+ configApiRef,
+ samlAuthApiRef,
+ oneloginAuthApiRef,
+ oidcAuthApiRef,
+ bitbucketAuthApiRef,
+ atlassianAuthApiRef,
+} from '@backstage/core-plugin-api';
+
+export const apis = [
+ createApiFactory({
+ api: discoveryApiRef,
+ deps: { configApi: configApiRef },
+ factory: ({ configApi }) =>
+ UrlPatternDiscovery.compile(
+ `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`,
+ ),
+ }),
+ createApiFactory({
+ api: alertApiRef,
+ deps: {},
+ factory: () => new AlertApiForwarder(),
+ }),
+ createApiFactory({
+ api: analyticsApiRef,
+ deps: {},
+ factory: () => new NoOpAnalyticsApi(),
+ }),
+ createApiFactory({
+ api: errorApiRef,
+ deps: { alertApi: alertApiRef },
+ factory: ({ alertApi }) => {
+ const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder());
+ UnhandledErrorForwarder.forward(errorApi, { hidden: false });
+ return errorApi;
+ },
+ }),
+ createApiFactory({
+ api: storageApiRef,
+ deps: { errorApi: errorApiRef },
+ factory: ({ errorApi }) => WebStorage.create({ errorApi }),
+ }),
+ createApiFactory({
+ api: oauthRequestApiRef,
+ deps: {},
+ factory: () => new OAuthRequestManager(),
+ }),
+ createApiFactory({
+ api: googleAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ GoogleAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: microsoftAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ MicrosoftAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: githubAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ GithubAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ defaultScopes: ['read:user'],
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: oktaAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ OktaAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: gitlabAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ GitlabAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: auth0AuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ Auth0Auth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: oauth2ApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: samlAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, configApi }) =>
+ SamlAuth.create({
+ discoveryApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: oneloginAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ OneLoginAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: oidcAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider: {
+ id: 'oidc',
+ title: 'Your Identity Provider',
+ icon: () => null,
+ },
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: bitbucketAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
+ BitbucketAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ defaultScopes: ['team'],
+ environment: configApi.getOptionalString('auth.environment'),
+ }),
+ }),
+ createApiFactory({
+ api: atlassianAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ configApi: configApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi, configApi }) => {
+ return AtlassianAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ environment: configApi.getOptionalString('auth.environment'),
+ });
+ },
+ }),
+];
diff --git a/packages/app-defaults/src/defaults/components.test.tsx b/packages/app-defaults/src/defaults/components.test.tsx
new file mode 100644
index 0000000000..865eeed8a7
--- /dev/null
+++ b/packages/app-defaults/src/defaults/components.test.tsx
@@ -0,0 +1,38 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { OptionallyWrapInRouter } from './components';
+
+describe('OptionallyWrapInRouter', () => {
+ it('should wrap with router if not yet inside a router', async () => {
+ render(Test);
+
+ expect(screen.getByText('Test')).toBeInTheDocument();
+ });
+
+ it('should not wrap with router if already inside a router', async () => {
+ render(
+
+ Test
+ ,
+ );
+
+ expect(screen.getByText('Test')).toBeInTheDocument();
+ });
+});
diff --git a/packages/app-defaults/src/defaults/components.tsx b/packages/app-defaults/src/defaults/components.tsx
new file mode 100644
index 0000000000..139bea4862
--- /dev/null
+++ b/packages/app-defaults/src/defaults/components.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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, { ReactNode } from 'react';
+import Button from '@material-ui/core/Button';
+import { ErrorPanel, Progress, ErrorPage } from '@backstage/core-components';
+import {
+ MemoryRouter,
+ useInRouterContext,
+ BrowserRouter,
+} from 'react-router-dom';
+import {
+ AppComponents,
+ BootErrorPageProps,
+ ErrorBoundaryFallbackProps,
+} from '@backstage/core-plugin-api';
+
+export function OptionallyWrapInRouter({ children }: { children: ReactNode }) {
+ if (useInRouterContext()) {
+ return <>{children}>;
+ }
+ return {children};
+}
+
+const DefaultNotFoundPage = () => (
+
+);
+
+const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => {
+ let message = '';
+ if (step === 'load-config') {
+ message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
+ } else if (step === 'load-chunk') {
+ message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`;
+ }
+ // TODO: figure out a nicer way to handle routing on the error page, when it can be done.
+ return (
+
+
+
+ );
+};
+
+const DefaultErrorBoundaryFallback = ({
+ error,
+ resetError,
+ plugin,
+}: ErrorBoundaryFallbackProps) => {
+ return (
+
+
+
+ );
+};
+
+/**
+ * Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}.
+ *
+ * @public
+ */
+export const components: AppComponents = {
+ Progress,
+ Router: BrowserRouter,
+ NotFoundErrorPage: DefaultNotFoundPage,
+ BootErrorPage: DefaultBootErrorPage,
+ ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
+};
diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/app-defaults/src/defaults/icons.tsx
similarity index 63%
rename from packages/core-app-api/src/app/icons.tsx
rename to packages/app-defaults/src/defaults/icons.tsx
index b234a292b1..c4f2d2b7e4 100644
--- a/packages/core-app-api/src/app/icons.tsx
+++ b/packages/app-defaults/src/defaults/icons.tsx
@@ -35,52 +35,27 @@ import MuiPeopleIcon from '@material-ui/icons/People';
import MuiPersonIcon from '@material-ui/icons/Person';
import MuiWarningIcon from '@material-ui/icons/Warning';
-type AppIconsKey =
- | 'brokenImage'
- | 'catalog'
- | 'scaffolder'
- | 'techdocs'
- | 'search'
- | 'chat'
- | 'dashboard'
- | 'docs'
- | 'email'
- | 'github'
- | 'group'
- | 'help'
- | 'kind:api'
- | 'kind:component'
- | 'kind:domain'
- | 'kind:group'
- | 'kind:location'
- | 'kind:system'
- | 'kind:user'
- | 'user'
- | 'warning';
-
-export type AppIcons = { [key in AppIconsKey]: IconComponent };
-
-export const defaultAppIcons: AppIcons = {
- brokenImage: MuiBrokenImageIcon,
+export const icons = {
+ brokenImage: MuiBrokenImageIcon as IconComponent,
// To be confirmed: see https://github.com/backstage/backstage/issues/4970
- catalog: MuiMenuBookIcon,
- scaffolder: MuiCreateNewFolderIcon,
- techdocs: MuiSubjectIcon,
- search: MuiSearchIcon,
- chat: MuiChatIcon,
- dashboard: MuiDashboardIcon,
- docs: MuiDocsIcon,
- email: MuiEmailIcon,
- github: MuiGitHubIcon,
- group: MuiPeopleIcon,
- help: MuiHelpIcon,
- 'kind:api': MuiExtensionIcon,
- 'kind:component': MuiMemoryIcon,
- 'kind:domain': MuiApartmentIcon,
- 'kind:group': MuiPeopleIcon,
- 'kind:location': MuiLocationOnIcon,
- 'kind:system': MuiCategoryIcon,
- 'kind:user': MuiPersonIcon,
- user: MuiPersonIcon,
- warning: MuiWarningIcon,
+ catalog: MuiMenuBookIcon as IconComponent,
+ scaffolder: MuiCreateNewFolderIcon as IconComponent,
+ techdocs: MuiSubjectIcon as IconComponent,
+ search: MuiSearchIcon as IconComponent,
+ chat: MuiChatIcon as IconComponent,
+ dashboard: MuiDashboardIcon as IconComponent,
+ docs: MuiDocsIcon as IconComponent,
+ email: MuiEmailIcon as IconComponent,
+ github: MuiGitHubIcon as IconComponent,
+ group: MuiPeopleIcon as IconComponent,
+ help: MuiHelpIcon as IconComponent,
+ 'kind:api': MuiExtensionIcon as IconComponent,
+ 'kind:component': MuiMemoryIcon as IconComponent,
+ 'kind:domain': MuiApartmentIcon as IconComponent,
+ 'kind:group': MuiPeopleIcon as IconComponent,
+ 'kind:location': MuiLocationOnIcon as IconComponent,
+ 'kind:system': MuiCategoryIcon as IconComponent,
+ 'kind:user': MuiPersonIcon as IconComponent,
+ user: MuiPersonIcon as IconComponent,
+ warning: MuiWarningIcon as IconComponent,
};
diff --git a/packages/app-defaults/src/defaults/index.ts b/packages/app-defaults/src/defaults/index.ts
new file mode 100644
index 0000000000..d9ff18bc8c
--- /dev/null
+++ b/packages/app-defaults/src/defaults/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 { apis } from './apis';
+export { components } from './components';
+export { icons } from './icons';
+export { themes } from './themes';
diff --git a/packages/app-defaults/src/defaults/themes.tsx b/packages/app-defaults/src/defaults/themes.tsx
new file mode 100644
index 0000000000..90a4a0f47f
--- /dev/null
+++ b/packages/app-defaults/src/defaults/themes.tsx
@@ -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 React from 'react';
+import { darkTheme, lightTheme } from '@backstage/theme';
+import DarkIcon from '@material-ui/icons/Brightness2';
+import LightIcon from '@material-ui/icons/WbSunny';
+import { ThemeProvider } from '@material-ui/core/styles';
+import CssBaseline from '@material-ui/core/CssBaseline';
+import { AppTheme } from '@backstage/core-plugin-api';
+
+export const themes: AppTheme[] = [
+ {
+ id: 'light',
+ title: 'Light Theme',
+ variant: 'light',
+ icon: ,
+ theme: lightTheme,
+ Provider: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ {
+ id: 'dark',
+ title: 'Dark Theme',
+ variant: 'dark',
+ icon: ,
+ theme: darkTheme,
+ Provider: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+];
diff --git a/packages/app-defaults/src/index.ts b/packages/app-defaults/src/index.ts
new file mode 100644
index 0000000000..16a6a693a5
--- /dev/null
+++ b/packages/app-defaults/src/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+/**
+ * Provides the default wiring of a Backstage App
+ *
+ * @packageDocumentation
+ */
+
+export { createApp } from './createApp';
+export type { OptionalAppOptions } from './createApp';
diff --git a/packages/app-defaults/src/setupTests.ts b/packages/app-defaults/src/setupTests.ts
new file mode 100644
index 0000000000..963c0f188b
--- /dev/null
+++ b/packages/app-defaults/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/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index 1dee41af56..f6f6049d68 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,50 @@
# example-app
+## 0.2.53
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/plugin-catalog@0.7.3
+ - @backstage/plugin-cost-insights@0.11.11
+ - @backstage/cli@0.9.0
+ - @backstage/plugin-gcp-projects@0.3.9
+ - @backstage/catalog-model@0.9.7
+ - @backstage/plugin-scaffolder@0.11.11
+ - @backstage/plugin-api-docs@0.6.14
+ - @backstage/plugin-azure-devops@0.1.4
+ - @backstage/plugin-badges@0.2.14
+ - @backstage/plugin-catalog-graph@0.2.2
+ - @backstage/plugin-catalog-import@0.7.4
+ - @backstage/plugin-catalog-react@0.6.4
+ - @backstage/plugin-circleci@0.2.29
+ - @backstage/plugin-cloudbuild@0.2.28
+ - @backstage/plugin-code-coverage@0.1.18
+ - @backstage/plugin-explore@0.3.21
+ - @backstage/plugin-github-actions@0.4.24
+ - @backstage/plugin-home@0.4.6
+ - @backstage/plugin-jenkins@0.5.12
+ - @backstage/plugin-kafka@0.2.21
+ - @backstage/plugin-kubernetes@0.4.20
+ - @backstage/plugin-lighthouse@0.2.30
+ - @backstage/plugin-newrelic@0.3.9
+ - @backstage/plugin-pagerduty@0.3.18
+ - @backstage/plugin-rollbar@0.3.19
+ - @backstage/plugin-search@0.4.18
+ - @backstage/plugin-sentry@0.3.29
+ - @backstage/plugin-tech-radar@0.4.12
+ - @backstage/plugin-techdocs@0.12.6
+ - @backstage/plugin-todo@0.1.15
+ - @backstage/plugin-user-settings@0.3.11
+ - @backstage/core-components@0.7.4
+ - @backstage/core-plugin-api@0.2.0
+ - @backstage/core-app-api@0.1.21
+ - @backstage/plugin-shortcuts@0.1.14
+ - @backstage/app-defaults@0.1.1
+ - @backstage/integration-react@0.1.14
+ - @backstage/plugin-graphiql@0.2.21
+ - @backstage/plugin-org@0.3.28
+
## 0.2.51
### Patch Changes
diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js
index 6a18d16198..11f31cfed3 100644
--- a/packages/app/cypress/integration/components/search/SearchPage.js
+++ b/packages/app/cypress/integration/components/search/SearchPage.js
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-const API_ENDPOINT = 'http://localhost:7000/api/search/query';
+const API_ENDPOINT = 'http://localhost:7007/api/search/query';
describe('SearchPage', () => {
describe('Given a search context with a term, results, and filter values', () => {
diff --git a/packages/app/package.json b/packages/app/package.json
index 9b443ff67e..448b1cbcf2 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,48 +1,49 @@
{
"name": "example-app",
- "version": "0.2.51",
+ "version": "0.2.53",
"private": true,
"bundled": true,
"dependencies": {
- "@backstage/catalog-model": "^0.9.5",
- "@backstage/cli": "^0.8.0",
- "@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-api-docs": "^0.6.12",
- "@backstage/plugin-azure-devops": "^0.1.1",
- "@backstage/plugin-badges": "^0.2.13",
- "@backstage/plugin-catalog": "^0.7.2",
- "@backstage/plugin-catalog-graph": "^0.2.1",
- "@backstage/plugin-catalog-import": "^0.7.3",
- "@backstage/plugin-catalog-react": "^0.6.1",
- "@backstage/plugin-circleci": "^0.2.27",
- "@backstage/plugin-cloudbuild": "^0.2.27",
- "@backstage/plugin-code-coverage": "^0.1.15",
- "@backstage/plugin-cost-insights": "^0.11.10",
- "@backstage/plugin-explore": "^0.3.20",
- "@backstage/plugin-gcp-projects": "^0.3.8",
- "@backstage/plugin-github-actions": "^0.4.22",
- "@backstage/plugin-graphiql": "^0.2.20",
- "@backstage/plugin-home": "^0.4.4",
- "@backstage/plugin-jenkins": "^0.5.11",
- "@backstage/plugin-kafka": "^0.2.19",
- "@backstage/plugin-kubernetes": "^0.4.17",
- "@backstage/plugin-lighthouse": "^0.2.29",
+ "@backstage/app-defaults": "^0.1.1",
+ "@backstage/catalog-model": "^0.9.7",
+ "@backstage/cli": "^0.9.0",
+ "@backstage/core-app-api": "^0.1.21",
+ "@backstage/core-components": "^0.7.4",
+ "@backstage/core-plugin-api": "^0.2.0",
+ "@backstage/integration-react": "^0.1.14",
+ "@backstage/plugin-api-docs": "^0.6.14",
+ "@backstage/plugin-azure-devops": "^0.1.4",
+ "@backstage/plugin-badges": "^0.2.14",
+ "@backstage/plugin-catalog": "^0.7.3",
+ "@backstage/plugin-catalog-graph": "^0.2.2",
+ "@backstage/plugin-catalog-import": "^0.7.4",
+ "@backstage/plugin-catalog-react": "^0.6.4",
+ "@backstage/plugin-circleci": "^0.2.29",
+ "@backstage/plugin-cloudbuild": "^0.2.28",
+ "@backstage/plugin-code-coverage": "^0.1.18",
+ "@backstage/plugin-cost-insights": "^0.11.11",
+ "@backstage/plugin-explore": "^0.3.21",
+ "@backstage/plugin-gcp-projects": "^0.3.9",
+ "@backstage/plugin-github-actions": "^0.4.24",
+ "@backstage/plugin-graphiql": "^0.2.21",
+ "@backstage/plugin-home": "^0.4.6",
+ "@backstage/plugin-jenkins": "^0.5.12",
+ "@backstage/plugin-kafka": "^0.2.21",
+ "@backstage/plugin-kubernetes": "^0.4.20",
+ "@backstage/plugin-lighthouse": "^0.2.30",
"@backstage/plugin-new-relic-dashboard": "^0.1.0",
- "@backstage/plugin-newrelic": "^0.3.8",
- "@backstage/plugin-org": "^0.3.27",
- "@backstage/plugin-pagerduty": "0.3.17",
- "@backstage/plugin-rollbar": "^0.3.18",
- "@backstage/plugin-scaffolder": "^0.11.8",
- "@backstage/plugin-search": "^0.4.15",
- "@backstage/plugin-sentry": "^0.3.26",
- "@backstage/plugin-shortcuts": "^0.1.12",
- "@backstage/plugin-tech-radar": "^0.4.11",
- "@backstage/plugin-techdocs": "^0.12.3",
- "@backstage/plugin-todo": "^0.1.14",
- "@backstage/plugin-user-settings": "^0.3.10",
+ "@backstage/plugin-newrelic": "^0.3.9",
+ "@backstage/plugin-org": "^0.3.28",
+ "@backstage/plugin-pagerduty": "0.3.18",
+ "@backstage/plugin-rollbar": "^0.3.19",
+ "@backstage/plugin-scaffolder": "^0.11.11",
+ "@backstage/plugin-search": "^0.4.18",
+ "@backstage/plugin-sentry": "^0.3.29",
+ "@backstage/plugin-shortcuts": "^0.1.14",
+ "@backstage/plugin-tech-radar": "^0.4.12",
+ "@backstage/plugin-techdocs": "^0.12.6",
+ "@backstage/plugin-todo": "^0.1.15",
+ "@backstage/plugin-user-settings": "^0.3.11",
"@backstage/search-common": "^0.2.0",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
@@ -64,8 +65,8 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/test-utils": "^0.1.19",
- "@rjsf/core": "^3.0.0",
+ "@backstage/test-utils": "^0.1.22",
+ "@rjsf/core": "^3.2.1",
"@testing-library/cypress": "^7.0.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx
index aa6781ce61..8c7cefeb2c 100644
--- a/packages/app/src/App.test.tsx
+++ b/packages/app/src/App.test.tsx
@@ -27,14 +27,14 @@ describe('App', () => {
data: {
app: {
title: 'Test',
- support: { url: 'http://localhost:7000/support' },
+ support: { url: 'http://localhost:7007/support' },
},
- backend: { baseUrl: 'http://localhost:7000' },
+ backend: { baseUrl: 'http://localhost:7007' },
lighthouse: {
baseUrl: 'http://localhost:3003',
},
techdocs: {
- storageUrl: 'http://localhost:7000/api/techdocs/static/docs',
+ storageUrl: 'http://localhost:7007/api/techdocs/static/docs',
},
},
context: 'test',
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 109ae7922e..401665b62e 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -26,7 +26,8 @@ import {
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
-import { createApp, FlatRoutes } from '@backstage/core-app-api';
+import { createApp } from '@backstage/app-defaults';
+import { FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
@@ -93,7 +94,6 @@ const app = createApp({
// Custom icon example
alert: AlarmIcon,
},
-
components: {
SignInPage: props => {
return (
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 7a666a020a..f365310595 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -29,7 +29,7 @@ import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
-import { SidebarSearch } from '@backstage/plugin-search';
+import { SidebarSearchModal } from '@backstage/plugin-search';
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
Sidebar,
@@ -79,7 +79,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
-
+
{/* Global nav, not org-specific */}
diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx
index 089cb9c7cf..a6878cd28b 100644
--- a/packages/app/src/components/catalog/EntityPage.test.tsx
+++ b/packages/app/src/components/catalog/EntityPage.test.tsx
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { EntityLayout } from '@backstage/plugin-catalog';
import {
DefaultStarredEntitiesApi,
@@ -22,7 +21,11 @@ import {
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { githubActionsApiRef } from '@backstage/plugin-github-actions';
-import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
+import {
+ MockStorageApi,
+ renderInTestApp,
+ TestApiProvider,
+} from '@backstage/test-utils';
import React from 'react';
import { cicdContent } from './EntityPage';
@@ -45,22 +48,22 @@ describe('EntityPage Test', () => {
const mockedApi = {
listWorkflowRuns: jest.fn().mockResolvedValue([]),
- getWorkflow: jest.fn(),
- getWorkflowRun: jest.fn(),
- reRunWorkflow: jest.fn(),
- listJobsForWorkflowRun: jest.fn(),
- downloadJobLogsForWorkflowRun: jest.fn(),
- } as jest.Mocked;
-
- const apis = ApiRegistry.with(githubActionsApiRef, mockedApi).with(
- starredEntitiesApiRef,
- new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
- );
+ };
describe('cicdContent', () => {
it('Should render GitHub Actions View', async () => {
const rendered = await renderInTestApp(
-
+
@@ -68,7 +71,7 @@ describe('EntityPage Test', () => {
- ,
+ ,
);
expect(rendered.getByText('ExampleComponent')).toBeInTheDocument();
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 3ce0ffb30f..1ebf171a93 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';
@@ -270,6 +271,18 @@ const errorsContent = (
);
+const pullRequestsContent = (
+
+
+
+
+
+
+
+
+
+);
+
const overviewContent = (
{entityWarningContent}
@@ -390,7 +403,7 @@ const serviceEntityPage = (
-
+ {pullRequestsContent}
@@ -455,7 +468,7 @@ const websiteEntityPage = (
-
+ {pullRequestsContent}
diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md
index f0368b55d4..3ab36cc679 100644
--- a/packages/backend-common/CHANGELOG.md
+++ b/packages/backend-common/CHANGELOG.md
@@ -1,5 +1,38 @@
# @backstage/backend-common
+## 0.9.10
+
+### Patch Changes
+
+- d7c1e0e34a: Added the `isDatabaseConflictError` function.
+- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers`
+- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs.
+- 1daada3a06: Paths can be specified in backend.reading.allow to further restrict allowed targets
+- 7ad9a07b27: 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.
+- 01f74aa878: Add `AbortSignal` support to `UrlReader`
+- a8732a1200: Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely.
+
+ After this change, the backend logger will be able to actually capture the error as it happens:
+
+ ```
+ 2021-11-11T10:54:21.334Z backstage info Initializing http server
+ 2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000
+ ```
+
+- 26b5da1c1a: 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.
+- Updated dependencies
+ - @backstage/config-loader@0.8.0
+ - @backstage/cli-common@0.1.6
+
+## 0.9.9
+
+### Patch Changes
+
+- 8c4cad0bf2: AWSS3UrlReader now throws a `NotModifiedError` (exported from @backstage/backend-common) when s3 returns a 304 response.
+- 0611f3b3e2: Reading app config from a remote server
+- Updated dependencies
+ - @backstage/config-loader@0.7.2
+
## 0.9.8
### Patch Changes
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 a174862b4a..9e0187137d 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
- "version": "0.9.8",
+ "version": "0.9.10",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -29,9 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/cli-common": "^0.1.5",
+ "@backstage/cli-common": "^0.1.6",
"@backstage/config": "^0.1.11",
- "@backstage/config-loader": "^0.7.1",
+ "@backstage/config-loader": "^0.8.0",
"@backstage/errors": "^0.1.4",
"@backstage/integration": "^0.6.9",
"@backstage/types": "^0.1.1",
@@ -39,7 +39,7 @@
"@lerna/project": "^4.0.0",
"@octokit/rest": "^18.5.3",
"@types/cors": "^2.8.6",
- "@types/dockerode": "^3.2.1",
+ "@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
"archiver": "^5.0.2",
"aws-sdk": "^2.840.0",
@@ -47,7 +47,7 @@
"concat-stream": "^2.0.0",
"cors": "^2.8.5",
"cross-fetch": "^3.0.6",
- "dockerode": "^3.2.1",
+ "dockerode": "^3.3.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "9.1.0",
@@ -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",
@@ -79,8 +80,8 @@
}
},
"devDependencies": {
- "@backstage/cli": "^0.8.1",
- "@backstage/test-utils": "^0.1.20",
+ "@backstage/cli": "^0.9.0",
+ "@backstage/test-utils": "^0.1.22",
"@types/archiver": "^5.1.0",
"@types/compression": "^1.7.0",
"@types/concat-stream": "^1.6.0",
diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts
index cfb655cbeb..941ad26e65 100644
--- a/packages/backend-common/src/config.ts
+++ b/packages/backend-common/src/config.ts
@@ -22,10 +22,13 @@ import {
loadConfigSchema,
loadConfig,
ConfigSchema,
+ ConfigTarget,
} from '@backstage/config-loader';
import { AppConfig, Config, ConfigReader } from '@backstage/config';
import { JsonValue } from '@backstage/types';
+import { isValidUrl } from './urls';
+
import { setRootLoggerRedactionList } from './logging/rootLogger';
// Fetch the schema and get all the secrets to pass to the rootLogger for redaction
@@ -178,7 +181,10 @@ export async function loadBackendConfig(options: {
argv: string[];
}): Promise {
const args = parseArgs(options.argv);
- const configPaths: string[] = [args.config ?? []].flat();
+
+ const configTargets: ConfigTarget[] = [args.config ?? []]
+ .flat()
+ .map(arg => (isValidUrl(arg) ? { url: arg } : { path: resolvePath(arg) }));
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
@@ -194,9 +200,10 @@ export async function loadBackendConfig(options: {
});
const config = new ObservableConfigProxy(options.logger);
- const configs = await loadConfig({
+ const { appConfigs } = await loadConfig({
configRoot: paths.targetRoot,
- configPaths: configPaths.map(opt => resolvePath(opt)),
+ configPaths: [],
+ configTargets: configTargets,
watch: {
onChange(newConfigs) {
options.logger.info(
@@ -220,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/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts
index c1e092706b..2f1e44b293 100644
--- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts
+++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts
@@ -37,7 +37,7 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery {
* for the internal one.
*
* The basePath defaults to `/api`, meaning the default full internal
- * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`.
+ * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
*/
static fromConfig(config: Config, options?: { basePath?: string }) {
const basePath = options?.basePath ?? '/api';
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.test.ts b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts
index 301543e8c9..a3ae8b647f 100644
--- a/packages/backend-common/src/reading/AwsS3UrlReader.test.ts
+++ b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts
@@ -27,6 +27,7 @@ import { UrlReaderPredicateTuple } from './types';
import AWSMock from 'aws-sdk-mock';
import aws from 'aws-sdk';
import path from 'path';
+import { NotModifiedError } from '@backstage/errors';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -131,29 +132,37 @@ describe('AwsS3UrlReader', () => {
});
describe('read', () => {
- AWSMock.setSDKInstance(aws);
- AWSMock.mock(
- 'S3',
- 'getObject',
- Buffer.from(
- require('fs').readFileSync(
- path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'),
+ let awsS3UrlReader: AwsS3UrlReader;
+
+ beforeAll(() => {
+ AWSMock.setSDKInstance(aws);
+ AWSMock.mock(
+ 'S3',
+ 'getObject',
+ Buffer.from(
+ require('fs').readFileSync(
+ path.resolve(
+ __dirname,
+ '__fixtures__/awsS3/awsS3-mock-object.yaml',
+ ),
+ ),
),
- ),
- );
- const s3 = new aws.S3();
- const awsS3UrlReader = new AwsS3UrlReader(
- new AwsS3Integration(
- readAwsS3IntegrationConfig(
- new ConfigReader({
- host: 'amazonaws.com',
- accessKeyId: 'fake-access-key',
- secretAccessKey: 'fake-secret-key',
- }),
+ );
+
+ const s3 = new aws.S3();
+ awsS3UrlReader = new AwsS3UrlReader(
+ new AwsS3Integration(
+ readAwsS3IntegrationConfig(
+ new ConfigReader({
+ host: 'amazonaws.com',
+ accessKeyId: 'fake-access-key',
+ secretAccessKey: 'fake-secret-key',
+ }),
+ ),
),
- ),
- { s3, treeResponseFactory },
- );
+ { s3, treeResponseFactory },
+ );
+ });
it('returns contents of an object in a bucket', async () => {
const response = await awsS3UrlReader.read(
@@ -176,32 +185,39 @@ describe('AwsS3UrlReader', () => {
});
describe('readUrl', () => {
- AWSMock.setSDKInstance(aws);
+ let awsS3UrlReader: AwsS3UrlReader;
- AWSMock.mock(
- 'S3',
- 'getObject',
- Buffer.from(
- require('fs').readFileSync(
- path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'),
+ beforeAll(() => {
+ AWSMock.setSDKInstance(aws);
+
+ AWSMock.mock(
+ 'S3',
+ 'getObject',
+ Buffer.from(
+ require('fs').readFileSync(
+ path.resolve(
+ __dirname,
+ '__fixtures__/awsS3/awsS3-mock-object.yaml',
+ ),
+ ),
),
- ),
- );
+ );
- const s3 = new aws.S3();
+ const s3 = new aws.S3();
- const awsS3UrlReader = new AwsS3UrlReader(
- new AwsS3Integration(
- readAwsS3IntegrationConfig(
- new ConfigReader({
- host: 'amazonaws.com',
- accessKeyId: 'fake-access-key',
- secretAccessKey: 'fake-secret-key',
- }),
+ awsS3UrlReader = new AwsS3UrlReader(
+ new AwsS3Integration(
+ readAwsS3IntegrationConfig(
+ new ConfigReader({
+ host: 'amazonaws.com',
+ accessKeyId: 'fake-access-key',
+ secretAccessKey: 'fake-secret-key',
+ }),
+ ),
),
- ),
- { s3, treeResponseFactory },
- );
+ { s3, treeResponseFactory },
+ );
+ });
it('returns contents of an object in a bucket', async () => {
const response = await awsS3UrlReader.readUrl(
@@ -223,40 +239,89 @@ describe('AwsS3UrlReader', () => {
);
});
});
+
+ describe('readUrl with etag', () => {
+ let awsS3UrlReader: AwsS3UrlReader;
+
+ beforeAll(() => {
+ AWSMock.setSDKInstance(aws);
+
+ AWSMock.mock('S3', 'getObject', (_, callback) => {
+ callback({ statusCode: 304 }, null);
+ });
+
+ const s3 = new aws.S3();
+
+ awsS3UrlReader = new AwsS3UrlReader(
+ new AwsS3Integration(
+ readAwsS3IntegrationConfig(
+ new ConfigReader({
+ host: 'amazonaws.com',
+ accessKeyId: 'fake-access-key',
+ secretAccessKey: 'fake-secret-key',
+ }),
+ ),
+ ),
+ { s3, treeResponseFactory },
+ );
+ });
+
+ it('returns contents of an object in a bucket', async () => {
+ await expect(
+ awsS3UrlReader.readUrl(
+ 'https://test-bucket.s3.us-east-2.amazonaws.com/awsS3-mock-object.yaml',
+ {
+ etag: 'abc123',
+ },
+ ),
+ ).rejects.toThrow(NotModifiedError);
+ });
+ });
+
describe('readTree', () => {
- const object: aws.S3.Types.Object = {
- Key: 'awsS3-mock-object.yaml',
- };
- const objectList: aws.S3.ObjectList = [object];
- const output: aws.S3.Types.ListObjectsV2Output = {
- Contents: objectList,
- };
- AWSMock.setSDKInstance(aws);
- AWSMock.mock('S3', 'listObjectsV2', output);
+ let awsS3UrlReader: AwsS3UrlReader;
- AWSMock.mock(
- 'S3',
- 'getObject',
- Buffer.from(
- require('fs').readFileSync(
- path.resolve(__dirname, '__fixtures__/awsS3/awsS3-mock-object.yaml'),
- ),
- ),
- );
+ beforeAll(() => {
+ const object: aws.S3.Types.Object = {
+ Key: 'awsS3-mock-object.yaml',
+ };
- const s3 = new aws.S3();
- const awsS3UrlReader = new AwsS3UrlReader(
- new AwsS3Integration(
- readAwsS3IntegrationConfig(
- new ConfigReader({
- host: '.amazonaws.com',
- accessKeyId: 'fake-access-key',
- secretAccessKey: 'fake-secret-key',
- }),
+ const objectList: aws.S3.ObjectList = [object];
+ const output: aws.S3.Types.ListObjectsV2Output = {
+ Contents: objectList,
+ };
+
+ AWSMock.setSDKInstance(aws);
+ AWSMock.mock('S3', 'listObjectsV2', output);
+
+ AWSMock.mock(
+ 'S3',
+ 'getObject',
+ Buffer.from(
+ require('fs').readFileSync(
+ path.resolve(
+ __dirname,
+ '__fixtures__/awsS3/awsS3-mock-object.yaml',
+ ),
+ ),
),
- ),
- { s3, treeResponseFactory },
- );
+ );
+
+ const s3 = new aws.S3();
+ awsS3UrlReader = new AwsS3UrlReader(
+ new AwsS3Integration(
+ readAwsS3IntegrationConfig(
+ new ConfigReader({
+ host: '.amazonaws.com',
+ accessKeyId: 'fake-access-key',
+ secretAccessKey: 'fake-secret-key',
+ }),
+ ),
+ ),
+ { s3, treeResponseFactory },
+ );
+ });
+
it('returns contents of an object in a bucket', async () => {
const response = await awsS3UrlReader.readTree(
'https://test.s3.us-east-2.amazonaws.com',
diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.ts b/packages/backend-common/src/reading/AwsS3UrlReader.ts
index 7fd0f6ba19..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,
@@ -27,7 +28,7 @@ import {
} from './types';
import getRawBody from 'raw-body';
import { AwsS3Integration, ScmIntegrations } from '@backstage/integration';
-import { ForwardedError } from '@backstage/errors';
+import { ForwardedError, NotModifiedError } from '@backstage/errors';
import { ListObjectsV2Output, ObjectList } from 'aws-sdk/clients/s3';
const parseURL = (
@@ -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,20 +155,28 @@ 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,
etag: etag,
};
} catch (e) {
+ if (e.statusCode === 304) {
+ throw new NotModifiedError();
+ }
+
throw new ForwardedError('Could not retrieve file from S3', e);
}
}
- async readTree(url: string): Promise {
+ async readTree(
+ url: string,
+ options?: ReadTreeOptions,
+ ): Promise {
try {
const { path, bucket, region } = parseURL(url);
const allObjects: ObjectList = [];
@@ -176,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-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
index ed16c9c9a7..b085cccfd6 100644
--- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
@@ -40,7 +40,7 @@ import {
} from './config';
import { createHttpServer, createHttpsServer } from './hostFactory';
-export const DEFAULT_PORT = 7000;
+export const DEFAULT_PORT = 7007;
// '' is express default, which listens to all interfaces
const DEFAULT_HOST = '';
// taken from the helmet source code - don't seem to be exported
@@ -174,27 +174,27 @@ export class ServiceBuilderImpl implements ServiceBuilder {
const server: http.Server = httpsSettings
? await createHttpsServer(app, httpsSettings, logger)
: createHttpServer(app, logger);
+ const stoppableServer = stoppable(server, 0);
+
+ useHotCleanup(this.module, () =>
+ stoppableServer.stop((e: any) => {
+ if (e) console.error(e);
+ }),
+ );
return new Promise((resolve, reject) => {
- app.on('error', e => {
- logger.error(`Failed to start up on port ${port}, ${e}`);
+ function handleStartupError(e: unknown) {
+ server.close();
reject(e);
+ }
+
+ server.on('error', handleStartupError);
+
+ server.listen(port, host, () => {
+ server.off('error', handleStartupError);
+ logger.info(`Listening on ${host}:${port}`);
+ resolve(stoppableServer);
});
-
- const stoppableServer = stoppable(
- server.listen(port, host, () => {
- logger.info(`Listening on ${host}:${port}`);
- }),
- 0,
- );
-
- useHotCleanup(this.module, () =>
- stoppableServer.stop((e: any) => {
- if (e) console.error(e);
- }),
- );
-
- resolve(stoppableServer);
});
}
diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts
index 57b814da63..d69b00f04c 100644
--- a/packages/backend-common/src/service/lib/config.ts
+++ b/packages/backend-common/src/service/lib/config.ts
@@ -63,8 +63,8 @@ type CustomOrigin = (
* @example
* ```json
* {
- * baseUrl: "http://localhost:7000",
- * listen: "0.0.0.0:7000"
+ * baseUrl: "http://localhost:7007",
+ * listen: "0.0.0.0:7007"
* }
* ```
*/
diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts
index 3765cbcfdd..2ad379f31e 100644
--- a/packages/backend-common/src/service/types.ts
+++ b/packages/backend-common/src/service/types.ts
@@ -34,7 +34,7 @@ export type ServiceBuilder = {
*
* If no port is specified, the service will first look for an environment
* variable named PORT and use that if present, otherwise it picks a default
- * port (7000).
+ * port (7007).
*
* @param port - The port to listen on
*/
diff --git a/packages/backend-common/src/urls.test.ts b/packages/backend-common/src/urls.test.ts
new file mode 100644
index 0000000000..c2a67fb849
--- /dev/null
+++ b/packages/backend-common/src/urls.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { isValidUrl } from './urls';
+
+describe('isValidUrl', () => {
+ it('should return true for url', () => {
+ const validUrl = isValidUrl('http://some.valid.url');
+ expect(validUrl).toBe(true);
+ });
+
+ it('should return false for absolute path', () => {
+ const validUrl = isValidUrl('/some/absolute/path');
+ expect(validUrl).toBe(false);
+ });
+
+ it('should return false for relative path', () => {
+ const validUrl = isValidUrl('../some/relative/path');
+ expect(validUrl).toBe(false);
+ });
+});
diff --git a/packages/backend-common/src/urls.ts b/packages/backend-common/src/urls.ts
new file mode 100644
index 0000000000..848cea25d9
--- /dev/null
+++ b/packages/backend-common/src/urls.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.
+ */
+
+export function isValidUrl(url: string): boolean {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
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..2bc6474615
--- /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.10",
+ "@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.9",
+ "@backstage/cli": "^0.9.0",
+ "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-test-utils/.snyk b/packages/backend-test-utils/.snyk
new file mode 100644
index 0000000000..ad62599e66
--- /dev/null
+++ b/packages/backend-test-utils/.snyk
@@ -0,0 +1,47 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.22.1
+# ignores vulnerabilities until expiry date; change duration by modifying expiry date
+ignore:
+ SNYK-JS-TAR-1579155:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1579152:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1579147:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536758:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536531:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536528:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+patch: {}
diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md
index 287e40a296..9927aa3cc7 100644
--- a/packages/backend-test-utils/CHANGELOG.md
+++ b/packages/backend-test-utils/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/backend-test-utils
+## 0.1.9
+
+### Patch Changes
+
+- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers`
+- Updated dependencies
+ - @backstage/cli@0.9.0
+ - @backstage/backend-common@0.9.10
+
## 0.1.8
### Patch Changes
diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json
index 2f29c2412b..c3d4f730b7 100644
--- a/packages/backend-test-utils/package.json
+++ b/packages/backend-test-utils/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-test-utils",
"description": "Test helpers library for Backstage backends",
- "version": "0.1.8",
+ "version": "0.1.9",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -30,18 +30,18 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/backend-common": "^0.9.7",
- "@backstage/cli": "^0.8.0",
+ "@backstage/backend-common": "^0.9.10",
+ "@backstage/cli": "^0.9.0",
"@backstage/config": "^0.1.9",
"knex": "^0.95.1",
"mysql2": "^2.2.5",
"pg": "^8.3.0",
"sqlite3": "^5.0.1",
- "testcontainers": "^7.10.0",
+ "testcontainers": "^7.23.0",
"uuid": "^8.0.0"
},
"devDependencies": {
- "@backstage/cli": "^0.8.0",
+ "@backstage/cli": "^0.9.0",
"jest": "^26.0.1"
},
"files": [
diff --git a/packages/backend/.snyk b/packages/backend/.snyk
new file mode 100644
index 0000000000..ad62599e66
--- /dev/null
+++ b/packages/backend/.snyk
@@ -0,0 +1,47 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.22.1
+# ignores vulnerabilities until expiry date; change duration by modifying expiry date
+ignore:
+ SNYK-JS-TAR-1579155:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1579152:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1579147:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536758:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536531:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+ SNYK-JS-TAR-1536528:
+ - 'sqlite3 > node-gyp > tar':
+ reason: >-
+ The only usage is via node-gyp; there is no unpacking of untrusted tar
+ files
+ expires: 2022-11-11T14:30:05.581Z
+ created: 2021-11-11T14:30:05.582Z
+patch: {}
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index 19e9bcdd67..7cf7d9ddaa 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,20 @@
# example-backend
+## 0.2.52
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/backend-common@0.9.9
+ - @backstage/plugin-jenkins-backend@0.1.7
+ - @backstage/plugin-search-backend-module-elasticsearch@0.0.5
+ - @backstage/plugin-scaffolder-backend@0.15.12
+ - @backstage/plugin-azure-devops-backend@0.2.0
+ - @backstage/catalog-client@0.5.1
+ - @backstage/plugin-auth-backend@0.4.7
+ - @backstage/plugin-catalog-backend@0.17.3
+ - @backstage/plugin-scaffolder-backend-module-rails@0.1.7
+
## 0.2.50
### Patch Changes
diff --git a/packages/backend/README.md b/packages/backend/README.md
index e6f0c899ca..c2145705e0 100644
--- a/packages/backend/README.md
+++ b/packages/backend/README.md
@@ -37,7 +37,7 @@ Substitute `x` for actual values, or leave them as
dummy values just to try out the backend without using the auth or sentry features.
You can also, instead of using dummy values for a huge number of environment variables, remove those config directly from app-config.yaml file located in the root folder.
-The backend starts up on port 7000 per default.
+The backend starts up on port 7007 per default.
### Debugging
diff --git a/packages/backend/package.json b/packages/backend/package.json
index f1d58252a4..4fb692f040 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.2.50",
+ "version": "0.2.52",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -24,39 +24,39 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.9.7",
- "@backstage/catalog-client": "^0.5.0",
- "@backstage/catalog-model": "^0.9.5",
+ "@backstage/backend-common": "^0.9.10",
+ "@backstage/catalog-client": "^0.5.2",
+ "@backstage/catalog-model": "^0.9.7",
"@backstage/config": "^0.1.10",
"@backstage/integration": "^0.6.8",
- "@backstage/plugin-app-backend": "^0.3.17",
- "@backstage/plugin-auth-backend": "^0.4.5",
- "@backstage/plugin-azure-devops-backend": "^0.1.3",
+ "@backstage/plugin-app-backend": "^0.3.19",
+ "@backstage/plugin-auth-backend": "^0.4.8",
+ "@backstage/plugin-azure-devops-backend": "^0.2.1",
"@backstage/plugin-badges-backend": "^0.1.11",
- "@backstage/plugin-catalog-backend": "^0.17.1",
+ "@backstage/plugin-catalog-backend": "^0.17.4",
"@backstage/plugin-code-coverage-backend": "^0.1.14",
"@backstage/plugin-graphql-backend": "^0.1.9",
- "@backstage/plugin-jenkins-backend": "^0.1.6",
+ "@backstage/plugin-jenkins-backend": "^0.1.7",
"@backstage/plugin-kubernetes-backend": "^0.3.18",
- "@backstage/plugin-kafka-backend": "^0.2.10",
+ "@backstage/plugin-kafka-backend": "^0.2.11",
"@backstage/plugin-proxy-backend": "^0.2.13",
"@backstage/plugin-rollbar-backend": "^0.1.15",
- "@backstage/plugin-scaffolder-backend": "^0.15.10",
- "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.5",
+ "@backstage/plugin-scaffolder-backend": "^0.15.13",
+ "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7",
"@backstage/plugin-search-backend": "^0.2.6",
"@backstage/plugin-search-backend-node": "^0.4.2",
- "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.4",
+ "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5",
"@backstage/plugin-search-backend-module-pg": "^0.2.1",
- "@backstage/plugin-techdocs-backend": "^0.10.5",
- "@backstage/plugin-tech-insights-backend": "^0.1.0",
+ "@backstage/plugin-techdocs-backend": "^0.10.8",
+ "@backstage/plugin-tech-insights-backend": "^0.1.1",
"@backstage/plugin-tech-insights-node": "^0.1.0",
"@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.0",
"@backstage/plugin-todo-backend": "^0.1.13",
"@gitbeaker/node": "^30.2.0",
"@octokit/rest": "^18.5.3",
"azure-devops-node-api": "^11.0.1",
- "dockerode": "^3.2.1",
- "example-app": "^0.2.51",
+ "dockerode": "^3.3.1",
+ "example-app": "^0.2.53",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-prom-bundle": "^6.3.6",
@@ -68,8 +68,8 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.8.0",
- "@types/dockerode": "^3.2.1",
+ "@backstage/cli": "^0.9.0",
+ "@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5"
},
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/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md
index 8243d09486..1b11e3bcb2 100644
--- a/packages/catalog-client/CHANGELOG.md
+++ b/packages/catalog-client/CHANGELOG.md
@@ -1,5 +1,19 @@
# @backstage/catalog-client
+## 0.5.2
+
+### Patch Changes
+
+- 3bf2238187: Update to the right version of @backstage/errors
+- Updated dependencies
+ - @backstage/catalog-model@0.9.7
+
+## 0.5.1
+
+### Patch Changes
+
+- 39e92897e4: Improved API documentation for catalog-client.
+
## 0.5.0
### Minor Changes
diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md
index b7bb713957..e16ca740f6 100644
--- a/packages/catalog-client/api-report.md
+++ b/packages/catalog-client/api-report.md
@@ -7,7 +7,7 @@ import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { Location as Location_2 } from '@backstage/catalog-model';
-// @public (undocumented)
+// @public
export type AddLocationRequest = {
type?: string;
target: string;
@@ -15,126 +15,106 @@ export type AddLocationRequest = {
presence?: 'optional' | 'required';
};
-// @public (undocumented)
+// @public
export type AddLocationResponse = {
location: Location_2;
entities: Entity[];
exists?: boolean;
};
-// @public (undocumented)
+// @public
export const CATALOG_FILTER_EXISTS: unique symbol;
-// @public (undocumented)
+// @public
export interface CatalogApi {
- // (undocumented)
addLocation(
location: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise>;
- // (undocumented)
getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getEntityByName(
name: EntityName,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getOriginLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
removeLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
}
-// @public (undocumented)
+// @public
export class CatalogClient implements CatalogApi {
constructor(options: { discoveryApi: DiscoveryApi });
- // (undocumented)
addLocation(
{ type, target, dryRun, presence }: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise>;
- // (undocumented)
getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getEntityByName(
compoundName: EntityName,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
getOriginLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise;
- // (undocumented)
removeLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
}
-// @public (undocumented)
+// @public
export type CatalogEntitiesRequest = {
filter?:
| Record[]
@@ -143,12 +123,12 @@ export type CatalogEntitiesRequest = {
fields?: string[] | undefined;
};
-// @public (undocumented)
+// @public
export type CatalogEntityAncestorsRequest = {
entityRef: string;
};
-// @public (undocumented)
+// @public
export type CatalogEntityAncestorsResponse = {
root: EntityName;
items: {
@@ -157,12 +137,12 @@ export type CatalogEntityAncestorsResponse = {
}[];
};
-// @public (undocumented)
+// @public
export type CatalogListResponse = {
items: T[];
};
-// @public (undocumented)
+// @public
export type CatalogRequestOptions = {
token?: string;
};
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index d4ba2c20f6..4ab727b6b3 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/catalog-client",
"description": "An isomorphic client for the catalog backend",
- "version": "0.5.0",
+ "version": "0.5.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,12 +30,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/catalog-model": "^0.9.5",
- "@backstage/errors": "^0.1.3",
+ "@backstage/catalog-model": "^0.9.7",
+ "@backstage/errors": "^0.1.4",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
- "@backstage/cli": "^0.8.0",
+ "@backstage/cli": "^0.9.0",
"@types/jest": "^26.0.7",
"msw": "^0.35.0"
},
diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts
index 75944467d8..29d01fbe5c 100644
--- a/packages/catalog-client/src/CatalogClient.ts
+++ b/packages/catalog-client/src/CatalogClient.ts
@@ -39,7 +39,11 @@ import {
} from './types/api';
import { DiscoveryApi } from './types/discovery';
-/** @public */
+/**
+ * A frontend and backend compatible client for communicating with the Backstage Catalog.
+ *
+ * @public
+ * */
export class CatalogClient implements CatalogApi {
private readonly discoveryApi: DiscoveryApi;
@@ -47,6 +51,16 @@ export class CatalogClient implements CatalogApi {
this.discoveryApi = options.discoveryApi;
}
+ /**
+ * Gets the Ancestors of an Entity.
+ *
+ * @param request - A request type for retrieving Entity ancestors.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A CatalogEntityAncestorsResponse.
+ *
+ * @public
+ */
async getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
@@ -61,6 +75,16 @@ export class CatalogClient implements CatalogApi {
);
}
+ /**
+ * Gets a Location by Id.
+ *
+ * @param id - A string containing the Id.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ *
+ * @public
+ */
async getLocationById(
id: string,
options?: CatalogRequestOptions,
@@ -72,6 +96,16 @@ export class CatalogClient implements CatalogApi {
);
}
+ /**
+ * Gets a set of Entities.
+ *
+ * @param request - A request type for retrieving an Entity.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A CatalogListResponse.
+ *
+ * @public
+ */
async getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
@@ -139,6 +173,16 @@ export class CatalogClient implements CatalogApi {
return { items: entities.sort(refCompare) };
}
+ /**
+ * Gets a given Entity based on a provided name.
+ *
+ * @param compoundName - A string containing the name.
+ * @param options - An object with your preferred options.
+ *
+ * @returns An {@link catalog-model#Entity}.
+ *
+ * @public
+ */
async getEntityByName(
compoundName: EntityName,
options?: CatalogRequestOptions,
@@ -153,6 +197,14 @@ export class CatalogClient implements CatalogApi {
);
}
+ /**
+ * Refreshes an Entity.
+ *
+ * @param entityRef - A string containing the entityREf
+ * @param options - An object with your preferred options.
+ *
+ * @public
+ */
async refreshEntity(entityRef: string, options?: CatalogRequestOptions) {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/refresh`,
@@ -171,6 +223,16 @@ export class CatalogClient implements CatalogApi {
}
}
+ /**
+ * Adds a location.
+ *
+ * @param options - An object with your preferred options.
+ * @param AddLocationRequest - A request object for adding locations.
+ *
+ * @returns An AddLocationResponse
+ *
+ * @public
+ */
async addLocation(
{ type = 'url', target, dryRun, presence }: AddLocationRequest,
options?: CatalogRequestOptions,
@@ -206,6 +268,16 @@ export class CatalogClient implements CatalogApi {
};
}
+ /**
+ * Gets an origin Location By Entity.
+ *
+ * @param entity - An Entity
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ *
+ * @public
+ */
async getOriginLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
@@ -225,6 +297,16 @@ export class CatalogClient implements CatalogApi {
.find(l => locationCompound === stringifyLocationReference(l));
}
+ /**
+ * Gets a Location by Entity.
+ *
+ * @param entity - An Entity
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ *
+ * @public
+ */
async getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
@@ -243,6 +325,14 @@ export class CatalogClient implements CatalogApi {
.find(l => locationCompound === stringifyLocationReference(l));
}
+ /**
+ * Removes a location as identified by Id.
+ *
+ * @param id - A string containing the Id
+ * @param options - An object with your preferred options.
+ *
+ * @public
+ */
async removeLocationById(
id: string,
options?: CatalogRequestOptions,
@@ -254,6 +344,14 @@ export class CatalogClient implements CatalogApi {
);
}
+ /**
+ * Removes an Entity as identified by Uid.
+ *
+ * @param uid - A string containing the Uid
+ * @param options - An object with your preferred options.
+ *
+ * @public
+ */
async removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts
index c7025e9452..6344caff7c 100644
--- a/packages/catalog-client/src/types/api.ts
+++ b/packages/catalog-client/src/types/api.ts
@@ -16,10 +16,18 @@
import { Entity, EntityName, Location } from '@backstage/catalog-model';
-/** @public */
+/**
+ * A Symbol to define if a catalog filter exists or not.
+ *
+ * @public
+ */
export const CATALOG_FILTER_EXISTS = Symbol('CATALOG_FILTER_EXISTS');
-/** @public */
+/**
+ * A request type for retrieving catalog Entities.
+ *
+ * @public
+ */
export type CatalogEntitiesRequest = {
filter?:
| Record[]
@@ -28,75 +36,176 @@ export type CatalogEntitiesRequest = {
fields?: string[] | undefined;
};
-/** @public */
+/**
+ * A request type for Catalog Entity Ancestor information.
+ *
+ * @public
+ */
export type CatalogEntityAncestorsRequest = {
entityRef: string;
};
-/** @public */
+/**
+ * A response type for Catalog Entity Ancestor information.
+ *
+ * @public
+ */
export type CatalogEntityAncestorsResponse = {
root: EntityName;
items: { entity: Entity; parents: EntityName[] }[];
};
-/** @public */
+/**
+ * A response type for the result of a catalog operation in list form.
+ *
+ * @public
+ */
export type CatalogListResponse = {
items: T[];
};
-/** @public */
+/**
+ * Options you can pass into a catalog request for additional information.
+ *
+ * @public
+ */
export type CatalogRequestOptions = {
token?: string;
};
-/** @public */
+/**
+ * Public functions for interacting with the Catalog API.
+ *
+ * @public
+ */
export interface CatalogApi {
- // Entities
+ /**
+ * Gets the Entities from the catalog based on your request and options.
+ *
+ * @param request - An object with your filters and fields.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A CatalogListResponse with items typed Catalog Model Entity.
+ *
+ */
getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise>;
+ /**
+ * Gets the Entity ancestor information from the catalog based on your request and options.
+ *
+ * @param request - An object with your filters and fields.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A CatalogEntityAncestorsResponse.
+ */
getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Gets a single Entity from the catalog by Entity name.
+ *
+ * @param name - A complete Entity name, with the full kind-namespace-name triplet.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Entity}.
+ */
getEntityByName(
name: EntityName,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Removes a single Entity from the catalog by Entity UID.
+ *
+ * @param uid - A string of the Entity UID.
+ * @param options - An object with your preferred options.
+ *
+ */
removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Refreshes an Entity in the catalog.
+ *
+ * @param entityRef - A string in the form of 'Kind/default:foo'.
+ * @param options - An object with your preferred options.
+ *
+ */
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
): Promise;
// Locations
+ /**
+ * Gets a Location object by ID from the catalog.
+ *
+ * @param id - A string in of the Location Id.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ */
getLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Gets origin location by Entity.
+ *
+ * @param entity - An {@link catalog-model#Entity}.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ */
getOriginLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Gets Location by Entity.
+ *
+ * @param entity - An {@link catalog-model#Entity}.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A {@link catalog-model#Location_2}.
+ */
getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Adds a Location.
+ *
+ * @param location - A request type for adding a Location to the catalog.
+ * @param options - An object with your preferred options.
+ *
+ * @returns A AddLocationResponse.
+ */
addLocation(
location: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise;
+ /**
+ * Removes a Location by Id.
+ *
+ * @param id - A string in of the Location Id.
+ * @param options - An object with your preferred options.
+ *
+ */
removeLocationById(
id: string,
options?: CatalogRequestOptions,
): Promise;
}
-/** @public */
+/**
+ * A request type for adding a Location to the catalog.
+ *
+ * @public
+ */
export type AddLocationRequest = {
type?: string;
target: string;
@@ -104,7 +213,11 @@ export type AddLocationRequest = {
presence?: 'optional' | 'required';
};
-/** @public */
+/**
+ * A response type for adding a Location to the catalog.
+ *
+ * @public
+ */
export type AddLocationResponse = {
location: Location;
entities: Entity[];
diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md
index e0c067c9c7..efd6513cdd 100644
--- a/packages/catalog-model/CHANGELOG.md
+++ b/packages/catalog-model/CHANGELOG.md
@@ -1,5 +1,11 @@
# @backstage/catalog-model
+## 0.9.7
+
+### Patch Changes
+
+- 8809b6c0dd: Update the json-schema dependency version.
+
## 0.9.6
### Patch Changes
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index b64e536578..0ba07aec3c 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/catalog-model",
"description": "Types and validators that help describe the model of a Backstage Catalog",
- "version": "0.9.6",
+ "version": "0.9.7",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -36,13 +36,13 @@
"@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"
},
"devDependencies": {
- "@backstage/cli": "^0.8.1",
+ "@backstage/cli": "^0.9.0",
"@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
"yaml": "^1.9.2"
diff --git a/packages/cli-common/CHANGELOG.md b/packages/cli-common/CHANGELOG.md
index f3978798c6..e588c02edf 100644
--- a/packages/cli-common/CHANGELOG.md
+++ b/packages/cli-common/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/cli-common
+## 0.1.6
+
+### Patch Changes
+
+- 677bfc2dd0: 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.
+
## 0.1.5
### Patch Changes
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/package.json b/packages/cli-common/package.json
index 5916cdb017..cdb4b01b6d 100644
--- a/packages/cli-common/package.json
+++ b/packages/cli-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli-common",
"description": "Common functionality used by cli, backend, and create-app",
- "version": "0.1.5",
+ "version": "0.1.6",
"private": false,
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts
index 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/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 48f38cbc8a..407c4fcbf2 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,41 @@
# @backstage/cli
+## 0.9.0
+
+### Minor Changes
+
+- 25f637f39f: Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority.
+
+### Patch Changes
+
+- 677bfc2dd0: 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.
+
+- 8809b6c0dd: Update the json-schema dependency version.
+- fdfd2f8a62: remove double config dep
+- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs.
+- 6dcfe227a2: Added a scaffolder backend module template for the `create` command.
+- 4ca3542fdd: 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.
+- 867ea81d15: bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1
+- 16d06f6ac3: 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.
+- Updated dependencies
+ - @backstage/config-loader@0.8.0
+ - @backstage/cli-common@0.1.6
+
+## 0.8.2
+
+### Patch Changes
+
+- dd355bca46: Switched to dynamically determining the packages that are unsafe to repack when executing the CLI within the Backstage main repo.
+- b393c4d4be: Fixed the `config:check` command that was incorrectly only validating frontend configuration. Also added a `--frontend` flag to the command which maintains that behavior.
+- 0611f3b3e2: Reading app config from a remote server
+- ec64d9590c: Make `ExitCodeError` call `super` early to avoid compiler warnings
+- 8af66229e7: Bumped `@spotify/eslint-config-react` from `v10` to `v12`, dropping support for Node.js v12.
+- a197708da9: Bumped `@spotify/eslint-config-typescript` from `v10` to `v12`, dropping support for Node.js v12.
+- Updated dependencies
+ - @backstage/config-loader@0.7.2
+
## 0.8.1
### Patch Changes
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index e889d23c3a..1d33372c60 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -95,6 +95,7 @@ async function getProjectConfig(targetPath, displayName) {
...(displayName && { displayName }),
rootDir: path.resolve(targetPath, 'src'),
coverageDirectory: path.resolve(targetPath, 'coverage'),
+ coverageProvider: 'v8',
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
moduleNameMapper: {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
diff --git a/packages/cli/config/jestSucraseTransform.js b/packages/cli/config/jestSucraseTransform.js
index 01acfee38d..c88909d21f 100644
--- a/packages/cli/config/jestSucraseTransform.js
+++ b/packages/cli/config/jestSucraseTransform.js
@@ -46,7 +46,11 @@ function process(source, filePath) {
}
if (transforms) {
- return transform(source, { transforms, filePath }).code;
+ return transform(source, {
+ transforms,
+ filePath,
+ disableESTransforms: true,
+ }).code;
}
return source;
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c023313fe7..1eed5a742c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
- "version": "0.8.1",
+ "version": "0.9.0",
"private": false,
"publishConfig": {
"access": "public"
@@ -28,22 +28,22 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
- "@backstage/cli-common": "^0.1.5",
+ "@backstage/cli-common": "^0.1.6",
"@backstage/config": "^0.1.11",
- "@backstage/config-loader": "^0.7.1",
+ "@backstage/config-loader": "^0.8.0",
"@backstage/errors": "^0.1.4",
"@backstage/types": "^0.1.1",
"@hot-loader/react-dom": "^16.13.0",
"@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",
"@spotify/eslint-config-base": "^12.0.0",
- "@spotify/eslint-config-react": "^10.0.0",
- "@spotify/eslint-config-typescript": "^10.0.0",
+ "@spotify/eslint-config-react": "^12.0.0",
+ "@spotify/eslint-config-typescript": "^12.0.0",
"@sucrase/jest-plugin": "^2.1.1",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.5.x",
@@ -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",
@@ -117,14 +117,14 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/backend-common": "^0.9.8",
+ "@backstage/backend-common": "^0.9.10",
"@backstage/config": "^0.1.11",
- "@backstage/core-components": "^0.7.2",
- "@backstage/core-plugin-api": "^0.1.12",
- "@backstage/core-app-api": "^0.1.19",
- "@backstage/dev-utils": "^0.2.12",
- "@backstage/test-utils": "^0.1.20",
- "@backstage/theme": "^0.2.12",
+ "@backstage/core-components": "^0.7.4",
+ "@backstage/core-plugin-api": "^0.2.0",
+ "@backstage/core-app-api": "^0.1.21",
+ "@backstage/dev-utils": "^0.2.13",
+ "@backstage/test-utils": "^0.1.22",
+ "@backstage/theme": "^0.2.13",
"@types/diff": "^5.0.0",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts
index 6bd575f9bf..b640ee4f37 100644
--- a/packages/cli/src/commands/config/print.ts
+++ b/packages/cli/src/commands/config/print.ts
@@ -25,6 +25,7 @@ export default async (cmd: Command) => {
args: cmd.config,
fromPackage: cmd.package,
mockEnv: cmd.lax,
+ fullVisibility: !cmd.frontend,
});
const visibility = getVisibilityOption(cmd);
const data = serializeConfigData(appConfigs, schema, visibility);
diff --git a/packages/cli/src/commands/config/validate.ts b/packages/cli/src/commands/config/validate.ts
index 9041272c5a..2d3ce60366 100644
--- a/packages/cli/src/commands/config/validate.ts
+++ b/packages/cli/src/commands/config/validate.ts
@@ -22,5 +22,6 @@ export default async (cmd: Command) => {
args: cmd.config,
fromPackage: cmd.package,
mockEnv: cmd.lax,
+ fullVisibility: !cmd.frontend,
});
};
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 302f76cc5d..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(
@@ -172,6 +196,7 @@ export function registerCommands(program: CommanderStatic) {
'Only load config schema that applies to the given package',
)
.option('--lax', 'Do not require environment variables to be set')
+ .option('--frontend', 'Only validate the frontend configuration')
.option(...configOption)
.description(
'Validate that the given configuration loads and matches schema',
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/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts
index cf9e873a66..863728b8f8 100644
--- a/packages/cli/src/lib/bundler/transforms.ts
+++ b/packages/cli/src/lib/bundler/transforms.ts
@@ -32,6 +32,23 @@ export const transforms = (options: TransformOptions): Transforms => {
const extraTransforms = isDev ? ['react-hot-loader'] : [];
+ // This ensures that styles inserted from the style-loader and any
+ // async style chunks are always given lower priority than JSS styles.
+ // Note that this function is stringified and executed in the browser
+ // after transpilation, so stick to simple syntax
+ function insertBeforeJssStyles(element: any) {
+ const head = document.head;
+ // This makes sure that any style elements we insert get put before the
+ // dynamic styles from JSS, such as the ones from `makeStyles()`.
+ // TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it.
+ const firstJssNode = head.querySelector('style[data-jss]');
+ if (!firstJssNode) {
+ head.appendChild(element);
+ } else {
+ head.insertBefore(element, firstJssNode);
+ }
+ }
+
const loaders = [
{
test: /\.(tsx?)$/,
@@ -112,7 +129,14 @@ export const transforms = (options: TransformOptions): Transforms => {
{
test: /\.css$/i,
use: [
- isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
+ isDev
+ ? {
+ loader: require.resolve('style-loader'),
+ options: {
+ insert: insertBeforeJssStyles,
+ },
+ }
+ : MiniCssExtractPlugin.loader,
{
loader: require.resolve('css-loader'),
options: {
@@ -132,6 +156,7 @@ export const transforms = (options: TransformOptions): Transforms => {
new MiniCssExtractPlugin({
filename: 'static/[name].[contenthash:8].css',
chunkFilename: 'static/[name].[id].[contenthash:8].css',
+ insert: insertBeforeJssStyles, // Only applies to async chunks
}),
);
}
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 3688d60089..d343a554f8 100644
--- a/packages/cli/src/lib/config.ts
+++ b/packages/cli/src/lib/config.ts
@@ -14,19 +14,30 @@
* limitations under the License.
*/
-import { loadConfig, loadConfigSchema } from '@backstage/config-loader';
+import {
+ ConfigTarget,
+ loadConfig,
+ loadConfigSchema,
+} from '@backstage/config-loader';
import { ConfigReader } from '@backstage/config';
import { paths } from './paths';
+import { isValidUrl } from './urls';
type Options = {
args: string[];
fromPackage?: string;
mockEnv?: boolean;
withFilteredKeys?: boolean;
+ fullVisibility?: boolean;
};
export async function loadCliConfig(options: Options) {
- const configPaths = options.args.map(arg => paths.resolveTarget(arg));
+ const configTargets: ConfigTarget[] = [];
+ options.args.forEach(arg => {
+ if (!isValidUrl(arg)) {
+ configTargets.push({ path: paths.resolveTarget(arg) });
+ }
+ });
// Consider all packages in the monorepo when loading in config
const { Project } = require('@lerna/project');
@@ -43,12 +54,13 @@ 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,
configRoot: paths.targetRoot,
- configPaths,
+ configPaths: [],
+ configTargets: configTargets,
});
// printing to stderr to not clobber stdout in case the cli command
@@ -59,7 +71,9 @@ export async function loadCliConfig(options: Options) {
try {
const frontendAppConfigs = schema.process(appConfigs, {
- visibility: ['frontend'],
+ visibility: options.fullVisibility
+ ? ['frontend', 'backend', 'secret']
+ : ['frontend'],
withFilteredKeys: options.withFilteredKeys,
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
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/errors.ts b/packages/cli/src/lib/errors.ts
index c0b4c45b6c..dd3955a10a 100644
--- a/packages/cli/src/lib/errors.ts
+++ b/packages/cli/src/lib/errors.ts
@@ -26,11 +26,11 @@ export class ExitCodeError extends CustomError {
readonly code: number;
constructor(code: number, command?: string) {
- if (command) {
- super(`Command '${command}' exited with code ${code}`);
- } else {
- super(`Child exited with code ${code}`);
- }
+ super(
+ command
+ ? `Command '${command}' exited with code ${code}`
+ : `Child exited with code ${code}`,
+ );
this.code = code;
}
}
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/urls.test.ts b/packages/cli/src/lib/urls.test.ts
new file mode 100644
index 0000000000..c2a67fb849
--- /dev/null
+++ b/packages/cli/src/lib/urls.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { isValidUrl } from './urls';
+
+describe('isValidUrl', () => {
+ it('should return true for url', () => {
+ const validUrl = isValidUrl('http://some.valid.url');
+ expect(validUrl).toBe(true);
+ });
+
+ it('should return false for absolute path', () => {
+ const validUrl = isValidUrl('/some/absolute/path');
+ expect(validUrl).toBe(false);
+ });
+
+ it('should return false for relative path', () => {
+ const validUrl = isValidUrl('../some/relative/path');
+ expect(validUrl).toBe(false);
+ });
+});
diff --git a/packages/cli/src/lib/urls.ts b/packages/cli/src/lib/urls.ts
new file mode 100644
index 0000000000..848cea25d9
--- /dev/null
+++ b/packages/cli/src/lib/urls.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.
+ */
+
+export function isValidUrl(url: string): boolean {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
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-backend-plugin/src/run.ts.hbs b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs
index 54d2716290..0a3ed2b7f0 100644
--- a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs
+++ b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs
@@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
-const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
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/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs
index a5a278e8a7..7e5d8f85f0 100644
--- a/packages/cli/templates/default-plugin/src/routes.ts.hbs
+++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs
@@ -1,5 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
- title: '{{ id }}',
+ id: '{{ id }}',
});
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/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md
index fbb2076167..bf0711ad6f 100644
--- a/packages/codemods/CHANGELOG.md
+++ b/packages/codemods/CHANGELOG.md
@@ -1,5 +1,24 @@
# @backstage/codemods
+## 0.1.22
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/cli-common@0.1.6
+ - @backstage/core-components@0.7.4
+ - @backstage/core-plugin-api@0.2.0
+ - @backstage/core-app-api@0.1.21
+
+## 0.1.21
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/core-components@0.7.3
+ - @backstage/core-plugin-api@0.1.13
+ - @backstage/core-app-api@0.1.20
+
## 0.1.20
### Patch Changes
diff --git a/packages/codemods/package.json b/packages/codemods/package.json
index 0dc0e9a0d5..bfb8d4d4b3 100644
--- a/packages/codemods/package.json
+++ b/packages/codemods/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/codemods",
"description": "A collection of codemods for Backstage projects",
- "version": "0.1.20",
+ "version": "0.1.22",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,7 +31,7 @@
"backstage-codemods": "bin/backstage-codemods"
},
"dependencies": {
- "@backstage/cli-common": "0.1.5",
+ "@backstage/cli-common": "0.1.6",
"@backstage/core-app-api": "*",
"@backstage/core-components": "*",
"@backstage/core-plugin-api": "*",
diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md
index dcaec00816..00ace6e704 100644
--- a/packages/config-loader/CHANGELOG.md
+++ b/packages/config-loader/CHANGELOG.md
@@ -1,5 +1,33 @@
# @backstage/config-loader
+## 0.8.0
+
+### Minor Changes
+
+- 1e99c73c75: 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)
+ ```
+
+### Patch Changes
+
+- 8809b6c0dd: Update the json-schema dependency version.
+- Updated dependencies
+ - @backstage/cli-common@0.1.6
+
+## 0.7.2
+
+### Patch Changes
+
+- 0611f3b3e2: Reading app config from a remote server
+- 26c5659c97: Bump msw to the same version as the rest
+
## 0.7.1
### Patch Changes
diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md
index 19b5ee9e27..e0fab50e5b 100644
--- a/packages/config-loader/api-report.md
+++ b/packages/config-loader/api-report.md
@@ -23,22 +23,54 @@ export type ConfigSchemaProcessingOptions = {
withFilteredKeys?: boolean;
};
+// Warning: (ae-missing-release-tag) "ConfigTarget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type ConfigTarget =
+ | {
+ path: string;
+ }
+ | {
+ url: string;
+ };
+
// @public
export type ConfigVisibility = 'frontend' | 'backend' | 'secret';
// @public
-export function loadConfig(options: LoadConfigOptions): Promise;
+export function loadConfig(
+ options: LoadConfigOptions,
+): Promise;
// @public
export type LoadConfigOptions = {
configRoot: string;
configPaths: string[];
+ configTargets: ConfigTarget[];
env?: string;
experimentalEnvFunc?: (name: string) => Promise;
- watch?: {
- onChange: (configs: AppConfig[]) => void;
- stopSignal?: Promise;
- };
+ remote?: LoadConfigOptionsRemote;
+ watch?: LoadConfigOptionsWatch;
+};
+
+// Warning: (ae-missing-release-tag) "LoadConfigOptionsRemote" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type LoadConfigOptionsRemote = {
+ reloadIntervalSeconds: number;
+};
+
+// Warning: (ae-missing-release-tag) "LoadConfigOptionsWatch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type LoadConfigOptionsWatch = {
+ onChange: (configs: AppConfig[]) => void;
+ stopSignal?: Promise;
+};
+
+// @public
+export type LoadConfigResult = {
+ appConfigs: AppConfig[];
};
// @public
@@ -71,4 +103,8 @@ export type TransformFunc = (
visibility: ConfigVisibility;
},
) => T | undefined;
+
+// Warnings were encountered during analysis:
+//
+// src/loader.d.ts:33:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/config-loader" does not have an export "configTargets"
```
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 604caa4008..b4c0350def 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
- "version": "0.7.1",
+ "version": "0.8.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,7 +30,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/cli-common": "^0.1.5",
+ "@backstage/cli-common": "^0.1.6",
"@backstage/config": "^0.1.11",
"@backstage/errors": "^0.1.4",
"@backstage/types": "^0.1.1",
@@ -38,12 +38,13 @@
"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",
"yaml": "^1.9.2",
- "yup": "^0.32.9"
+ "yup": "^0.32.9",
+ "node-fetch": "2.6.5"
},
"devDependencies": {
"@types/jest": "^26.0.7",
@@ -51,7 +52,8 @@
"@types/mock-fs": "^4.10.0",
"@types/node": "^14.14.32",
"@types/yup": "^0.29.13",
- "mock-fs": "^5.1.0"
+ "mock-fs": "^5.1.0",
+ "msw": "^0.35.0"
},
"files": [
"dist"
diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts
index 888c68ef70..2699b3571f 100644
--- a/packages/config-loader/src/index.ts
+++ b/packages/config-loader/src/index.ts
@@ -29,4 +29,10 @@ export type {
TransformFunc,
} from './lib';
export { loadConfig } from './loader';
-export type { LoadConfigOptions } from './loader';
+export type {
+ ConfigTarget,
+ LoadConfigOptions,
+ LoadConfigOptionsWatch,
+ LoadConfigOptionsRemote,
+ LoadConfigResult,
+} from './loader';
diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts
index 32a0191cae..ca88b771ba 100644
--- a/packages/config-loader/src/lib/index.ts
+++ b/packages/config-loader/src/lib/index.ts
@@ -17,3 +17,4 @@
export { readEnvConfig } from './env';
export * from './transform';
export * from './schema';
+export { isValidUrl } from './urls';
diff --git a/packages/config-loader/src/lib/urls.test.ts b/packages/config-loader/src/lib/urls.test.ts
new file mode 100644
index 0000000000..c2a67fb849
--- /dev/null
+++ b/packages/config-loader/src/lib/urls.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { isValidUrl } from './urls';
+
+describe('isValidUrl', () => {
+ it('should return true for url', () => {
+ const validUrl = isValidUrl('http://some.valid.url');
+ expect(validUrl).toBe(true);
+ });
+
+ it('should return false for absolute path', () => {
+ const validUrl = isValidUrl('/some/absolute/path');
+ expect(validUrl).toBe(false);
+ });
+
+ it('should return false for relative path', () => {
+ const validUrl = isValidUrl('../some/relative/path');
+ expect(validUrl).toBe(false);
+ });
+});
diff --git a/packages/config-loader/src/lib/urls.ts b/packages/config-loader/src/lib/urls.ts
new file mode 100644
index 0000000000..848cea25d9
--- /dev/null
+++ b/packages/config-loader/src/lib/urls.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.
+ */
+
+export function isValidUrl(url: string): boolean {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts
index 3f51e76936..b5c986eeb9 100644
--- a/packages/config-loader/src/loader.test.ts
+++ b/packages/config-loader/src/loader.test.ts
@@ -18,8 +18,43 @@ import { AppConfig } from '@backstage/config';
import { loadConfig } from './loader';
import mockFs from 'mock-fs';
import fs from 'fs-extra';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
describe('loadConfig', () => {
+ const server = setupServer();
+ const initialLoaderHandler = rest.get(
+ `https://some.domain.io/app-config.yaml`,
+ (_req, res, ctx) => {
+ return res(
+ ctx.body(
+ `app:
+ title: Remote Example App
+ sessionKey: 'abc123'
+ escaped: \$\${Escaped}
+ `,
+ ),
+ );
+ },
+ );
+
+ const reloadHandler = rest.get(
+ `https://some.domain.io/app-config.yaml`,
+ (_req, res, ctx) => {
+ return res(
+ ctx.body(
+ `app:
+ title: NEW ReMOTe ExaMPLe App
+ sessionKey: 'abc123'
+ escaped: \$\${Escaped}
+ `,
+ ),
+ );
+ },
+ );
+
+ beforeAll(() => server.listen());
+
beforeEach(() => {
process.env.MY_SECRET = 'is-secret';
process.env.SUBSTITUTE_ME = 'substituted';
@@ -32,6 +67,13 @@ describe('loadConfig', () => {
$file: secrets/session-key.txt
escaped: \$\${Escaped}
`,
+ '/root/app-config2.yaml': `
+ app:
+ title: Example App 2
+ sessionKey:
+ $file: secrets/session-key.txt
+ escaped: \$\${Escaped}
+ `,
'/root/app-config.development.yaml': `
app:
sessionKey: development-key
@@ -67,110 +109,190 @@ describe('loadConfig', () => {
afterEach(() => {
mockFs.restore();
+ server.resetHandlers();
});
+ afterAll(() => server.close());
+
it('load config from default path', async () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
+ 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('loads config with secrets', async () => {
+ it('load config from remote path', async () => {
+ server.use(initialLoaderHandler);
+
+ const configUrl = 'https://some.domain.io/app-config.yaml';
+
+ await expect(
+ loadConfig({
+ configRoot: '/root',
+ configPaths: [],
+ configTargets: [{ url: configUrl }],
+ env: 'production',
+ remote: {
+ reloadIntervalSeconds: 30,
+ },
+ }),
+ ).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 () => {
+ await expect(
+ loadConfig({
+ configRoot: '/root',
+ configPaths: ['/root/app-config2.yaml'],
+ configTargets: [{ path: '/root/app-config.yaml' }],
+ env: 'production',
+ }),
+ ).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}',
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ it('loads config with secrets from single file', async () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: ['/root/app-config.yaml'],
+ 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 () => {
await expect(
loadConfig({
configRoot: '/root',
- configPaths: [
- '/root/app-config.yaml',
- '/root/app-config.development.yaml',
+ configPaths: [],
+ configTargets: [
+ { path: '/root/app-config.yaml' },
+ { path: '/root/app-config.development.yaml' },
],
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 () => {
await expect(
loadConfig({
configRoot: '/root',
- configPaths: ['/root/app-config.substitute.yaml'],
+ configPaths: [],
+ 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 () => {
@@ -181,23 +303,26 @@ describe('loadConfig', () => {
loadConfig({
configRoot: '/root',
configPaths: [],
+ configTargets: [],
watch: {
onChange: onChange.resolve,
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: {
@@ -218,12 +343,66 @@ describe('loadConfig', () => {
stopSignal.resolve();
});
+ it('watches remote config urls', async () => {
+ server.use(initialLoaderHandler);
+
+ const onChange = defer();
+ const stopSignal = defer();
+
+ const configUrl = 'https://some.domain.io/app-config.yaml';
+ await expect(
+ loadConfig({
+ configRoot: '/root',
+ configPaths: [],
+ configTargets: [{ url: configUrl }],
+ watch: {
+ onChange: onChange.resolve,
+ stopSignal: stopSignal.promise,
+ },
+ remote: {
+ reloadIntervalSeconds: 1,
+ },
+ }),
+ ).resolves.toEqual({
+ appConfigs: [
+ {
+ context: configUrl,
+ data: {
+ app: {
+ title: 'Remote Example App',
+ sessionKey: 'abc123',
+ escaped: '${Escaped}',
+ },
+ },
+ },
+ ],
+ });
+
+ server.use(reloadHandler);
+
+ await expect(onChange.promise).resolves.toEqual([
+ {
+ context: configUrl,
+ data: {
+ app: {
+ title: 'NEW ReMOTe ExaMPLe App',
+ sessionKey: 'abc123',
+ escaped: '${Escaped}',
+ },
+ },
+ },
+ ]);
+
+ stopSignal.resolve();
+ });
+
it('stops watching config files', async () => {
const stopSignal = defer();
await loadConfig({
configRoot: '/root',
configPaths: [],
+ configTargets: [],
watch: {
onChange: () => {
expect('not').toBe('called');
diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts
index 6803d61786..6a92ed519d 100644
--- a/packages/config-loader/src/loader.ts
+++ b/packages/config-loader/src/loader.ts
@@ -17,15 +17,38 @@
import fs from 'fs-extra';
import yaml from 'yaml';
import chokidar from 'chokidar';
-import { resolve as resolvePath, dirname, isAbsolute, basename } from 'path';
+import { basename, dirname, isAbsolute, resolve as resolvePath } from 'path';
import { AppConfig } from '@backstage/config';
import { ForwardedError } from '@backstage/errors';
import {
applyConfigTransforms,
- readEnvConfig,
createIncludeTransform,
createSubstitutionTransform,
+ isValidUrl,
+ readEnvConfig,
} from './lib';
+import fetch from 'node-fetch';
+
+export type ConfigTarget = { path: string } | { url: string };
+
+export type LoadConfigOptionsWatch = {
+ /**
+ * A listener that is called when a config file is changed.
+ */
+ onChange: (configs: AppConfig[]) => void;
+
+ /**
+ * An optional signal that stops the watcher once the promise resolves.
+ */
+ stopSignal?: Promise;
+};
+
+export type LoadConfigOptionsRemote = {
+ /**
+ * An optional remote config reloading period, in seconds
+ */
+ reloadIntervalSeconds: number;
+};
/**
* Options that control the loading of configuration files in the backend.
@@ -36,9 +59,14 @@ export type LoadConfigOptions = {
// The root directory of the config loading context. Used to find default configs.
configRoot: string;
- // Absolute paths to load config files from. Configs from earlier paths have lower priority.
+ /** Absolute paths to load config files from. Configs from earlier paths have lower priority.
+ * @deprecated Use {@link configTargets} instead.
+ */
configPaths: string[];
+ // Paths to load config files from. Configs from earlier paths have lower priority.
+ configTargets: ConfigTarget[];
+
/** @deprecated This option has been removed */
env?: string;
@@ -49,20 +77,26 @@ export type LoadConfigOptions = {
*/
experimentalEnvFunc?: (name: string) => Promise;
+ /**
+ * An optional remote config
+ */
+ remote?: LoadConfigOptionsRemote;
+
/**
* An optional configuration that enables watching of config files.
*/
- watch?: {
- /**
- * A listener that is called when a config file is changed.
- */
- onChange: (configs: AppConfig[]) => void;
+ watch?: LoadConfigOptionsWatch;
+};
- /**
- * An optional signal that stops the watcher once the promise resolves.
- */
- stopSignal?: Promise;
- };
+/**
+ * Results of loading configuration files.
+ * @public
+ */
+export type LoadConfigResult = {
+ /**
+ * Array of all loaded configs.
+ */
+ appConfigs: AppConfig[];
};
/**
@@ -72,13 +106,33 @@ export type LoadConfigOptions = {
*/
export async function loadConfig(
options: LoadConfigOptions,
-): Promise {
- const { configRoot, experimentalEnvFunc: envFunc, watch } = options;
- const configPaths = options.configPaths.slice();
+): Promise {
+ const { configRoot, experimentalEnvFunc: envFunc, watch, remote } = options;
+
+ const configPaths: string[] = options.configTargets
+ .slice()
+ .filter((e): e is { path: string } => e.hasOwnProperty('path'))
+ .map(configTarget => configTarget.path);
+
+ // Append deprecated configPaths to the absolute config paths received via configTargets.
+ options.configPaths.forEach(cp => {
+ if (!configPaths.includes(cp)) {
+ configPaths.push(cp);
+ }
+ });
+
+ const configUrls: string[] = options.configTargets
+ .slice()
+ .filter((e): e is { url: string } => e.hasOwnProperty('url'))
+ .map(configTarget => configTarget.url);
+
+ if (remote === undefined && configUrls.length > 0) {
+ throw new Error(`Remote config detected but this feature is turned off`);
+ }
// If no paths are provided, we default to reading
// `app-config.yaml` and, if it exists, `app-config.local.yaml`
- if (configPaths.length === 0) {
+ if (configPaths.length === 0 && configUrls.length === 0) {
configPaths.push(resolvePath(configRoot, 'app-config.yaml'));
const localConfig = resolvePath(configRoot, 'app-config.local.yaml');
@@ -114,22 +168,67 @@ export async function loadConfig(
return configs;
};
- let fileConfigs;
+ const loadRemoteConfigFiles = async () => {
+ const configs: AppConfig[] = [];
+
+ const readConfigFromUrl = async (url: string) => {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Could not read config file at ${url}`);
+ }
+
+ return await response.text();
+ };
+
+ for (let i = 0; i < configUrls.length; i++) {
+ const configUrl = configUrls[i];
+ if (!isValidUrl(configUrl)) {
+ throw new Error(`Config load path is not valid: '${configUrl}'`);
+ }
+
+ const remoteConfigContent = await readConfigFromUrl(configUrl);
+ if (!remoteConfigContent) {
+ throw new Error(`Config is not valid`);
+ }
+ const configYaml = yaml.parse(remoteConfigContent);
+ const substitutionTransform = createSubstitutionTransform(env);
+ const data = await applyConfigTransforms(configRoot, configYaml, [
+ substitutionTransform,
+ ]);
+
+ configs.push({ data, context: configUrl });
+ }
+
+ return configs;
+ };
+
+ let fileConfigs: AppConfig[];
try {
fileConfigs = await loadConfigFiles();
} catch (error) {
throw new ForwardedError('Failed to read static configuration file', error);
}
+ let remoteConfigs: AppConfig[] = [];
+ if (remote) {
+ try {
+ remoteConfigs = await loadRemoteConfigFiles();
+ } catch (error) {
+ throw new ForwardedError(
+ `Failed to read remote configuration file`,
+ error,
+ );
+ }
+ }
+
const envConfigs = await readEnvConfig(process.env);
- // Set up config file watching if requested by the caller
- if (watch) {
- let currentSerializedConfig = JSON.stringify(fileConfigs);
-
+ const watchConfigFile = (watchProp: LoadConfigOptionsWatch) => {
const watcher = chokidar.watch(configPaths, {
usePolling: process.env.NODE_ENV === 'test',
});
+
+ let currentSerializedConfig = JSON.stringify(fileConfigs);
watcher.on('change', async () => {
try {
const newConfigs = await loadConfigFiles();
@@ -140,18 +239,71 @@ export async function loadConfig(
}
currentSerializedConfig = newSerializedConfig;
- watch.onChange([...newConfigs, ...envConfigs]);
+ watchProp.onChange([...remoteConfigs, ...newConfigs, ...envConfigs]);
} catch (error) {
console.error(`Failed to reload configuration files, ${error}`);
}
});
- if (watch.stopSignal) {
- watch.stopSignal.then(() => {
+ if (watchProp.stopSignal) {
+ watchProp.stopSignal.then(() => {
watcher.close();
});
}
+ };
+
+ const watchRemoteConfig = (
+ watchProp: LoadConfigOptionsWatch,
+ remoteProp: LoadConfigOptionsRemote,
+ ) => {
+ const hasConfigChanged = async (
+ oldRemoteConfigs: AppConfig[],
+ newRemoteConfigs: AppConfig[],
+ ) => {
+ return (
+ JSON.stringify(oldRemoteConfigs) !== JSON.stringify(newRemoteConfigs)
+ );
+ };
+
+ let handle: NodeJS.Timeout | undefined;
+ try {
+ handle = setInterval(async () => {
+ console.info(`Checking for config update`);
+ const newRemoteConfigs = await loadRemoteConfigFiles();
+ if (await hasConfigChanged(remoteConfigs, newRemoteConfigs)) {
+ remoteConfigs = newRemoteConfigs;
+ console.info(`Remote config change, reloading config ...`);
+ watchProp.onChange([...remoteConfigs, ...fileConfigs, ...envConfigs]);
+ console.info(`Remote config reloaded`);
+ }
+ }, remoteProp.reloadIntervalSeconds * 1000);
+ } catch (error) {
+ console.error(`Failed to reload configuration files, ${error}`);
+ }
+
+ if (watchProp.stopSignal) {
+ watchProp.stopSignal.then(() => {
+ if (handle !== undefined) {
+ console.info(`Stopping remote config watch`);
+ clearInterval(handle);
+ handle = undefined;
+ }
+ });
+ }
+ };
+
+ // Set up config file watching if requested by the caller
+ if (watch) {
+ watchConfigFile(watch);
}
- return [...fileConfigs, ...envConfigs];
+ if (watch && remote) {
+ watchRemoteConfig(watch, remote);
+ }
+
+ return {
+ appConfigs: remote
+ ? [...remoteConfigs, ...fileConfigs, ...envConfigs]
+ : [...fileConfigs, ...envConfigs],
+ };
}
diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md
index ff75f5bf81..b1d590a7bd 100644
--- a/packages/core-app-api/CHANGELOG.md
+++ b/packages/core-app-api/CHANGELOG.md
@@ -1,5 +1,41 @@
# @backstage/core-app-api
+## 0.1.22
+
+### Patch Changes
+
+- Reverted the `createApp` TypeScript type to match the one before version `0.1.21`, as it was an accidental breaking change.
+
+## 0.1.21
+
+### Patch Changes
+
+- 0b1de52732: Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names.
+- ecd1fcb80a: Deprecated the `BackstagePluginWithAnyOutput` type.
+- 32bfbafb0f: Start exporting and marking several types as public to address errors in the API report.
+- 014cbf8cb9: The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement.
+
+ The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`.
+
+ The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely.
+
+- 475edb5bc5: move the BehaviorSubject init into the constructor
+- Updated dependencies
+ - @backstage/core-components@0.7.4
+ - @backstage/core-plugin-api@0.2.0
+ - @backstage/app-defaults@0.1.1
+
+## 0.1.20
+
+### Patch Changes
+
+- 78c512ce8f: I have added default icons for the catalog, scaffolder, techdocs, and search.
+- 8b4284cd5c: Improve API documentation for @backstage/core-plugin-api
+- Updated dependencies
+ - @backstage/core-components@0.7.3
+ - @backstage/theme@0.2.13
+ - @backstage/core-plugin-api@0.1.13
+
## 0.1.19
### Patch Changes
diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md
index 09d3313c84..e69ee4f325 100644
--- a/packages/core-app-api/api-report.md
+++ b/packages/core-app-api/api-report.md
@@ -27,9 +27,11 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ConfigReader } from '@backstage/config';
+import { createApp as createApp_2 } from '@backstage/app-defaults';
import { DiscoveryApi } 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';
@@ -93,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,
@@ -103,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;
@@ -117,6 +121,12 @@ export const ApiProvider: {
};
// @public
+export type ApiProviderProps = {
+ apis: ApiHolder;
+ children: ReactNode;
+};
+
+// @public @deprecated
export class ApiRegistry implements ApiHolder {
constructor(apis: Map);
// Warning: (ae-forgotten-export) The symbol "ApiRegistryBuilder" needs to be exported by the entry point index.d.ts
@@ -149,7 +159,7 @@ export type AppComponents = {
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType;
- ThemeProvider: ComponentType<{}>;
+ ThemeProvider?: ComponentType<{}>;
SignInPage?: ComponentType;
};
@@ -163,23 +173,52 @@ export type AppContext = {
getComponents(): AppComponents;
};
+// @public
+export type AppIcons = {
+ 'kind:api': IconComponent;
+ 'kind:component': IconComponent;
+ 'kind:domain': IconComponent;
+ 'kind:group': IconComponent;
+ 'kind:location': IconComponent;
+ 'kind:system': IconComponent;
+ 'kind:user': IconComponent;
+ brokenImage: IconComponent;
+ catalog: IconComponent;
+ chat: IconComponent;
+ dashboard: IconComponent;
+ docs: IconComponent;
+ email: IconComponent;
+ github: IconComponent;
+ group: IconComponent;
+ help: IconComponent;
+ scaffolder: IconComponent;
+ search: IconComponent;
+ techdocs: IconComponent;
+ user: IconComponent;
+ warning: IconComponent;
+};
+
// @public
export type AppOptions = {
apis?: Iterable;
- icons?: Partial & {
+ defaultApis?: Iterable;
+ icons: AppIcons & {
[key in string]: IconComponent;
};
- plugins?: BackstagePluginWithAnyOutput[];
- components?: Partial;
- themes?: AppTheme[];
+ 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 {
@@ -210,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,
@@ -233,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[];
@@ -241,7 +287,7 @@ export type BackstageApp = {
getRouter(): ComponentType<{}>;
};
-// @public
+// @public @deprecated
export type BackstagePluginWithAnyOutput = Omit<
BackstagePlugin,
'output'
@@ -285,10 +331,13 @@ export type BootErrorPageProps = {
export { ConfigReader };
-// Warning: (ae-forgotten-export) The symbol "PrivateAppImpl" needs to be exported by the entry point index.d.ts
-//
+// @public @deprecated
+export function createApp(
+ options?: Parameters[0],
+): BackstageApp & AppContext;
+
// @public
-export function createApp(options?: AppOptions): PrivateAppImpl;
+export function createSpecializedApp(options: AppOptions): BackstageApp;
// @public
export const defaultConfigLoader: AppConfigLoader;
@@ -298,15 +347,11 @@ export class ErrorAlerter implements ErrorApi {
constructor(alertApi: AlertApi, errorApi: ErrorApi);
// (undocumented)
error$(): Observable<{
- error: {
- name: string;
- message: string;
- stack?: string | undefined;
- };
- context?: ErrorContext | undefined;
+ error: ErrorApiError;
+ context?: ErrorApiErrorContext | undefined;
}>;
// (undocumented)
- post(error: Error, context?: ErrorContext): void;
+ post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
}
// @public
@@ -314,10 +359,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
@@ -353,6 +398,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({
@@ -454,12 +501,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,
@@ -468,7 +514,7 @@ export class OAuth2
oauthRequestApi,
defaultScopes,
scopeTransform,
- }: CreateOptions): OAuth2;
+ }: OAuth2CreateOptions): OAuth2;
// (undocumented)
getAccessToken(
scope?: string | string[],
@@ -490,6 +536,11 @@ export class OAuth2
signOut(): Promise;
}
+// @public
+export type OAuth2CreateOptions = OAuthApiCreateOptions & {
+ scopeTransform?: (scopes: string[]) => string[];
+};
+
// @public
export type OAuth2Session = {
providerInfo: {
@@ -502,6 +553,12 @@ export type OAuth2Session = {
backstageIdentity: BackstageIdentity;
};
+// @public
+export type OAuthApiCreateOptions = AuthApiCreateOptions & {
+ oauthRequestApi: OAuthRequestApi;
+ defaultScopes?: string[];
+};
+
// @public
export class OAuthRequestManager implements OAuthRequestApi {
// (undocumented)
@@ -524,25 +581,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,
@@ -563,6 +626,13 @@ export class SamlAuth
signOut(): Promise;
}
+// @public
+export type SamlSession = {
+ userId: string;
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentity;
+};
+
// @public
export type SignInPageProps = {
onResult(result: SignInResult): void;
@@ -578,7 +648,7 @@ export type SignInResult = {
// @public
export class UnhandledErrorForwarder {
- static forward(errorApi: ErrorApi, errorContext: ErrorContext): void;
+ static forward(errorApi: ErrorApi, errorContext: ErrorApiErrorContext): void;
}
// @public
@@ -607,9 +677,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
-// src/app/types.d.ts:152:5 - (ae-forgotten-export) The symbol "AppIcons" needs to be exported by the entry point index.d.ts
```
diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json
index f40e3d5eb7..96c021b1ee 100644
--- a/packages/core-app-api/package.json
+++ b/packages/core-app-api/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-app-api",
"description": "Core app API used by Backstage apps",
- "version": "0.1.19",
+ "version": "0.1.22",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,10 +29,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/core-components": "^0.7.2",
+ "@backstage/app-defaults": "^0.1.1",
+ "@backstage/core-components": "^0.7.4",
"@backstage/config": "^0.1.11",
- "@backstage/core-plugin-api": "^0.1.12",
- "@backstage/theme": "^0.2.12",
+ "@backstage/core-plugin-api": "^0.2.0",
+ "@backstage/theme": "^0.2.13",
"@backstage/types": "^0.1.1",
"@backstage/version-bridge": "^0.1.0",
"@material-ui/core": "^4.12.2",
@@ -46,8 +47,8 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/cli": "^0.8.1",
- "@backstage/test-utils": "^0.1.20",
+ "@backstage/cli": "^0.9.0",
+ "@backstage/test-utils": "^0.1.22",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^7.0.2",
diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts
index 7fcbde97df..5e404a35d7 100644
--- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts
+++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts
@@ -26,10 +26,10 @@ describe('UrlPatternDiscovery', () => {
it('should use a plain pattern', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
- 'http://localhost:7000/{{ pluginId }}',
+ 'http://localhost:7007/{{ pluginId }}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
- 'http://localhost:7000/my-plugin',
+ 'http://localhost:7007/my-plugin',
);
});
diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts
index aa921c2595..cba8344977 100644
--- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts
+++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts
@@ -30,7 +30,7 @@ export class UrlPatternDiscovery implements DiscoveryApi {
* interpolation done for the template is to replace instances of `{{pluginId}}`
* with the ID of the plugin being requested.
*
- * Example pattern: `http://localhost:7000/api/{{ pluginId }}`
+ * Example pattern: `http://localhost:7007/api/{{ pluginId }}`
*/
static compile(pattern: string): UrlPatternDiscovery {
const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/);
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/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts
index d0f137f0be..76cf01d20a 100644
--- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts
@@ -15,7 +15,6 @@
*/
import MockOAuthApi from './MockOAuthApi';
-import PowerIcon from '@material-ui/icons/Power';
describe('MockOAuthApi', () => {
it('should trigger all requests', async () => {
@@ -24,13 +23,13 @@ describe('MockOAuthApi', () => {
const authHandler1 = jest.fn().mockImplementation(() => authResult);
const requester1 = mock.createAuthRequester({
- provider: { icon: PowerIcon, title: 'Test' },
+ provider: { icon: () => null, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn().mockResolvedValue('other');
const requester2 = mock.createAuthRequester({
- provider: { icon: PowerIcon, title: 'Test' },
+ provider: { icon: () => null, title: 'Test' },
onAuthRequest: authHandler2,
});
@@ -67,13 +66,13 @@ describe('MockOAuthApi', () => {
const authHandler1 = jest.fn();
const requester1 = mock.createAuthRequester({
- provider: { icon: PowerIcon, title: 'Test' },
+ provider: { icon: () => null, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn();
const requester2 = mock.createAuthRequester({
- provider: { icon: PowerIcon, title: 'Test' },
+ provider: { icon: () => null, title: 'Test' },
onAuthRequest: authHandler2,
});
diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts
index d8faf90229..3110197ad9 100644
--- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts
+++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import ProviderIcon from '@material-ui/icons/AcUnit';
import { OAuthRequestManager } from './OAuthRequestManager';
describe('OAuthRequestManager', () => {
@@ -27,7 +26,7 @@ describe('OAuthRequestManager', () => {
const requester = manager.createAuthRequester({
provider: {
title: 'My Provider',
- icon: ProviderIcon,
+ icon: () => null,
},
onAuthRequest: async () => 'hello',
});
diff --git a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts
index 227d70e494..caad40ddbf 100644
--- a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import AtlassianIcon from '@material-ui/icons/AcUnit';
import { atlassianAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'atlassian',
title: 'Atlassian',
- icon: AtlassianIcon,
+ icon: () => null,
};
/**
diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
index 69462942e4..0a158ccb9e 100644
--- a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import Auth0Icon from '@material-ui/icons/AcUnit';
import { auth0AuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
- icon: Auth0Icon,
+ icon: () => null,
};
/**
diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts
index c9db93906a..e488580c4d 100644
--- a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import BitbucketIcon from '@material-ui/icons/FormatBold';
import {
BackstageIdentity,
bitbucketAuthApiRef,
@@ -37,7 +36,7 @@ export type BitbucketAuthResponse = {
const DEFAULT_PROVIDER = {
id: 'bitbucket',
title: 'Bitbucket',
- icon: BitbucketIcon,
+ icon: () => null,
};
/**
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 01e7e77b8e..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
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
@@ -48,7 +47,7 @@ export type GithubAuthResponse = {
const DEFAULT_PROVIDER = {
id: 'github',
title: 'GitHub',
- icon: GithubIcon,
+ icon: () => null,
};
/**
@@ -117,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/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
index af581d0018..f46f2ef72e 100644
--- a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import GitlabIcon from '@material-ui/icons/AcUnit';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'GitLab',
- icon: GitlabIcon,
+ icon: () => null,
};
/**
diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts
index 916c52382e..a6f37b250f 100644
--- a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import GoogleIcon from '@material-ui/icons/AcUnit';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
- icon: GoogleIcon,
+ icon: () => null,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
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/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
index 09bd7f67a2..148be66873 100644
--- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import MicrosoftIcon from '@material-ui/icons/AcUnit';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
- icon: MicrosoftIcon,
+ icon: () => null,
};
/**
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 6c0759d967..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
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
@@ -33,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[];
};
@@ -51,7 +54,7 @@ export type OAuth2Response = {
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
- icon: OAuth2Icon,
+ icon: () => null,
};
/**
@@ -74,7 +77,7 @@ export default class OAuth2
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
- }: CreateOptions) {
+ }: OAuth2CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
@@ -115,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/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts
index 294fb496af..465a124051 100644
--- a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import OktaIcon from '@material-ui/icons/AcUnit';
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
- icon: OktaIcon,
+ icon: () => null,
};
const OKTA_OIDC_SCOPES: Set = new Set([
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 f49ea4cde8..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
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import OneLoginIcon from '@material-ui/icons/AcUnit';
import {
oneloginAuthApiRef,
OAuthRequestApi,
@@ -23,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;
@@ -33,7 +36,7 @@ type CreateOptions = {
const DEFAULT_PROVIDER = {
id: 'onelogin',
title: 'onelogin',
- icon: OneLoginIcon,
+ icon: () => null,
};
const OIDC_SCOPES: Set = new Set([
@@ -59,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 f2668dedd2..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
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import SamlIcon from '@material-ui/icons/AcUnit';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
@@ -42,7 +41,7 @@ export type SamlAuthResponse = {
const DEFAULT_PROVIDER = {
id: 'saml',
title: 'SAML',
- icon: SamlIcon,
+ icon: () => null,
};
/**
@@ -80,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/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts
index 29c8b1cfe4..e433381dd1 100644
--- a/packages/core-app-api/src/apis/system/ApiRegistry.ts
+++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts
@@ -36,6 +36,7 @@ class ApiRegistryBuilder {
* A registry for utility APIs.
*
* @public
+ * @deprecated Will be removed, use {@link @backstage/test-utils#TestApiProvider} or {@link @backstage/test-utils#TestApiRegistry} instead.
*/
export class ApiRegistry implements ApiHolder {
static builder() {
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/App.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx
similarity index 87%
rename from packages/core-app-api/src/app/App.test.tsx
rename to packages/core-app-api/src/app/AppManager.test.tsx
index ab287a3ece..07401660e8 100644
--- a/packages/core-app-api/src/app/App.test.tsx
+++ b/packages/core-app-api/src/app/AppManager.test.tsx
@@ -20,11 +20,9 @@ import {
renderWithEffects,
withLogCollector,
} from '@backstage/test-utils';
-import { lightTheme } from '@backstage/theme';
import { render, screen } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
-import { defaultAppIcons } from './icons';
import {
configApiRef,
createApiFactory,
@@ -37,8 +35,8 @@ import {
createRoutableExtension,
analyticsApiRef,
} from '@backstage/core-plugin-api';
-import { generateBoundRoutes, PrivateAppImpl } from './App';
-import { AppThemeProvider } from './AppThemeProvider';
+import { generateBoundRoutes, AppManager } from './AppManager';
+import { AppComponents, AppIcons } from './types';
describe('generateBoundRoutes', () => {
it('runs happy path', () => {
@@ -128,7 +126,7 @@ describe('Integration Test', () => {
const HiddenComponent = plugin2.provide(
createRoutableExtension({
name: 'HiddenComponent',
- component: () => Promise.resolve((_: { path?: string }) => ),
+ component: () => Promise.resolve(() => ),
mountPoint: plugin2RouteRef,
}),
);
@@ -137,7 +135,7 @@ describe('Integration Test', () => {
createRoutableExtension({
name: 'ExposedComponent',
component: () =>
- Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
+ Promise.resolve(() => {
const link1 = useRouteRef(plugin1RouteRef);
const link2 = useRouteRef(plugin2RouteRef);
const subLink1 = useRouteRef(subRouteRef1);
@@ -169,6 +167,7 @@ describe('Integration Test', () => {
const NavigateComponent = plugin1.provide(
createRoutableExtension({
+ name: 'NavigateComponent',
component: () =>
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
return ;
@@ -177,17 +176,19 @@ describe('Integration Test', () => {
}),
);
- const components = {
+ const components: AppComponents = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
ErrorBoundaryFallback: () => null,
- ThemeProvider: AppThemeProvider,
+ ThemeProvider: ({ children }) => <>{children}>,
};
+ const icons = {} as AppIcons;
+
it('runs happy paths', async () => {
- const app = new PrivateAppImpl({
+ const app = new AppManager({
apis: [noOpAnalyticsApi],
defaultApis: [],
themes: [
@@ -195,12 +196,13 @@ describe('Integration Test', () => {
id: 'light',
title: 'Light Theme',
variant: 'light',
- theme: lightTheme,
+ Provider: ({ children }) => <>{children}>,
},
],
- icons: defaultAppIcons,
+ icons,
plugins: [],
components,
+ configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
@@ -218,8 +220,8 @@ describe('Integration Test', () => {
-
-
+ } />
+ } />
,
@@ -241,7 +243,7 @@ describe('Integration Test', () => {
});
it('runs happy paths without optional routes', async () => {
- const app = new PrivateAppImpl({
+ const app = new AppManager({
apis: [noOpAnalyticsApi],
defaultApis: [],
themes: [
@@ -249,12 +251,13 @@ describe('Integration Test', () => {
id: 'light',
title: 'Light Theme',
variant: 'light',
- theme: lightTheme,
+ Provider: ({ children }) => <>{children}>,
},
],
- icons: defaultAppIcons,
+ icons,
plugins: [],
components,
+ configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
@@ -270,8 +273,8 @@ describe('Integration Test', () => {
-
-
+ } />
+ } />
,
@@ -298,7 +301,7 @@ describe('Integration Test', () => {
}),
];
- const app = new PrivateAppImpl({
+ const app = new AppManager({
apis,
defaultApis: [],
themes: [
@@ -306,10 +309,10 @@ describe('Integration Test', () => {
id: 'light',
title: 'Light Theme',
variant: 'light',
- theme: lightTheme,
+ Provider: ({ children }) => <>{children}>,
},
],
- icons: defaultAppIcons,
+ icons,
plugins: [
createPlugin({
id: 'test',
@@ -317,6 +320,7 @@ describe('Integration Test', () => {
}),
],
components,
+ configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
@@ -332,8 +336,8 @@ describe('Integration Test', () => {
-
-
+ } />
+ } />
,
@@ -348,7 +352,7 @@ describe('Integration Test', () => {
it('should track route changes via analytics api', async () => {
const mockAnalyticsApi = new MockAnalyticsApi();
const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)];
- const app = new PrivateAppImpl({
+ const app = new AppManager({
apis,
defaultApis: [],
themes: [
@@ -356,12 +360,13 @@ describe('Integration Test', () => {
id: 'light',
title: 'Light Theme',
variant: 'light',
- theme: lightTheme,
+ Provider: ({ children }) => <>{children}>,
},
],
- icons: defaultAppIcons,
+ icons,
plugins: [],
components,
+ configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
@@ -378,7 +383,7 @@ describe('Integration Test', () => {
} />
- } />
+ } />
,
@@ -408,7 +413,7 @@ describe('Integration Test', () => {
});
it('should throw some error when the route has duplicate params', () => {
- const app = new PrivateAppImpl({
+ const app = new AppManager({
apis: [],
defaultApis: [],
themes: [
@@ -416,12 +421,13 @@ describe('Integration Test', () => {
id: 'light',
title: 'Light Theme',
variant: 'light',
- theme: lightTheme,
+ Provider: ({ children }) => <>{children}>,
},
],
- icons: defaultAppIcons,
+ icons,
plugins: [],
components,
+ configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
@@ -438,9 +444,9 @@ describe('Integration Test', () => {
-
-
-
+ }>
+ } />
+
,
diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/AppManager.tsx
similarity index 94%
rename from packages/core-app-api/src/app/App.tsx
rename to packages/core-app-api/src/app/AppManager.tsx
index 8cb3f167fb..950f57bb7d 100644
--- a/packages/core-app-api/src/app/App.tsx
+++ b/packages/core-app-api/src/app/AppManager.tsx
@@ -77,6 +77,8 @@ import {
SignInPageProps,
SignInResult,
} from './types';
+import { AppThemeProvider } from './AppThemeProvider';
+import { defaultConfigLoader } from './defaultConfigLoader';
export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) {
const result = new Map();
@@ -121,17 +123,6 @@ function getBasePath(configApi: Config) {
return pathname;
}
-type FullAppOptions = {
- apis: Iterable;
- icons: NonNullable;
- plugins: BackstagePlugin[];
- components: AppComponents;
- themes: AppTheme[];
- configLoader?: AppConfigLoader;
- defaultApis: Iterable;
- bindRoutes?: AppOptions['bindRoutes'];
-};
-
function useConfigLoader(
configLoader: AppConfigLoader | undefined,
components: AppComponents,
@@ -151,7 +142,7 @@ function useConfigLoader(
noConfigNode =