diff --git a/.changeset/chatty-cooks-begin.md b/.changeset/chatty-cooks-begin.md
deleted file mode 100644
index e32d22d532..0000000000
--- a/.changeset/chatty-cooks-begin.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@backstage/plugin-kubernetes': patch
-'@backstage/plugin-kubernetes-backend': patch
----
-
-Add AWS auth provider for Kubernetes
diff --git a/.changeset/clean-rocks-ring.md b/.changeset/clean-rocks-ring.md
deleted file mode 100644
index 706f3640c2..0000000000
--- a/.changeset/clean-rocks-ring.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@backstage/cli': minor
----
-
-We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures.
-The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule.
-
-Every plugin and app has the ability to override these rules if you want to ignore them for now.
-
-You can reset back to the default behaviour by using the following in your own `.eslint.js`
-
-```js
-rules: {
- 'no-use-before-define': 'off'
-}
-```
-
-Because of the nature of this change, we're unable to provide a grace period for the update :(
diff --git a/.changeset/cost-insights-curvy-dingos-live.md b/.changeset/cost-insights-curvy-dingos-live.md
deleted file mode 100644
index f15d3b0492..0000000000
--- a/.changeset/cost-insights-curvy-dingos-live.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-cost-insights': patch
----
-
-Allow expand functionality to top panel product chart tooltip.
diff --git a/.changeset/fast-flowers-tickle.md b/.changeset/fast-flowers-tickle.md
deleted file mode 100644
index 3eacb6f1a0..0000000000
--- a/.changeset/fast-flowers-tickle.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-'@backstage/plugin-kafka': minor
-'@backstage/plugin-kafka-backend': minor
----
-
-Added support for multiple Kafka clusters and multiple consumers per component.
-Note that this introduces several breaking changes.
-
-1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way:
-
-```diff
-kafka:
- clientId: backstage
-- brokers:
-- - localhost:9092
-+ clusters:
-+ - name: prod
-+ brokers:
-+ - localhost:9092
-```
-
-2. Configuration of services has changed as well to support multiple clusters:
-
-```diff
- annotations:
-- kafka.apache.org/consumer-groups: consumer
-+ kafka.apache.org/consumer-groups: prod/consumer
-```
-
-3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path:
- `/consumers/${clusterId}/${consumerGroup}/offsets`
diff --git a/.changeset/five-games-grin.md b/.changeset/five-games-grin.md
deleted file mode 100644
index 492c92880c..0000000000
--- a/.changeset/five-games-grin.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/cli': patch
----
-
-Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set.
diff --git a/.changeset/forty-jobs-occur.md b/.changeset/forty-jobs-occur.md
deleted file mode 100644
index b1620a7535..0000000000
--- a/.changeset/forty-jobs-occur.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-'@backstage/plugin-catalog': patch
----
-
-Add `EntityRefLinks` that shows one or multiple entity ref links.
-
-Change the about card and catalog table to use `EntityRefLinks` due to the
-nature of relations to support multiple relations per type.
diff --git a/.changeset/happy-crabs-punch.md b/.changeset/happy-crabs-punch.md
deleted file mode 100644
index 4c68dbea1a..0000000000
--- a/.changeset/happy-crabs-punch.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@backstage/config-loader': patch
----
-
-Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `$${...}`, which will end up as `${...}`.
-
-For example:
-
-```yaml
-app:
- baseUrl: https://${BASE_HOST}
-```
diff --git a/.changeset/healthy-cameras-suffer.md b/.changeset/healthy-cameras-suffer.md
deleted file mode 100644
index 13d74011dc..0000000000
--- a/.changeset/healthy-cameras-suffer.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-kubernetes': patch
----
-
-Improve error reporting for plugin misconfiguration.
diff --git a/.changeset/honest-jokes-rush.md b/.changeset/honest-jokes-rush.md
deleted file mode 100644
index 31ec6202da..0000000000
--- a/.changeset/honest-jokes-rush.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-auth-backend': patch
----
-
-Use .text instead of .json for ALB key response
diff --git a/.changeset/lucky-guests-mate.md b/.changeset/lucky-guests-mate.md
deleted file mode 100644
index fd733aac75..0000000000
--- a/.changeset/lucky-guests-mate.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/config-loader': minor
----
-
-Removed support for the deprecated `$data` placeholder.
diff --git a/.changeset/techdocs-dirty-rivers-hope.md b/.changeset/techdocs-dirty-rivers-hope.md
deleted file mode 100644
index 3f49afa529..0000000000
--- a/.changeset/techdocs-dirty-rivers-hope.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/techdocs-common': patch
----
-
-Add rate limiter for concurrent execution of file uploads in AWS and Google publishers
diff --git a/.changeset/tender-parrots-itch.md b/.changeset/tender-parrots-itch.md
deleted file mode 100644
index 00757b9b22..0000000000
--- a/.changeset/tender-parrots-itch.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/config-loader': minor
----
-
-Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files.
diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index 425e6eea6f..2991a6babd 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -130,6 +130,7 @@ middleware
minikube
Minikube
misconfiguration
+misconfigured
misgendering
mkdocs
Mkdocs
@@ -217,6 +218,7 @@ Spotify
sqlite
squidfunk
src
+stdout
stefanalund
subkey
subtree
@@ -257,6 +259,7 @@ Voi
Wealthsimple
Weaveworks
Webpack
+winston
www
WWW
xyz
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c35e2a88ff..aa2feebb3e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,10 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
+ INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }}
+ INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }}
+ INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }}
+ INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }}
steps:
- uses: actions/checkout@v2
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 62be0a6cea..0fe1642394 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -3,6 +3,7 @@ name: E2E Test Linux
on:
pull_request:
paths-ignore:
+ - '.changeset/**'
- 'contrib/**'
- 'docs/**'
- 'microsite/**'
diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml
index ca402c48d5..a9b5bfdf0e 100644
--- a/.github/workflows/master-win.yml
+++ b/.github/workflows/master-win.yml
@@ -1,6 +1,7 @@
name: Master Build Windows
on:
+ workflow_dispatch:
push:
branches: [master]
@@ -15,6 +16,10 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
+ INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }}
+ INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }}
+ INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }}
+ INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }}
steps:
- uses: actions/checkout@v2
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index 68b08224ac..425e1172a4 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -1,6 +1,7 @@
name: Main Master Build
on:
+ workflow_dispatch:
push:
branches: [master]
@@ -18,6 +19,10 @@ jobs:
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
+ INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }}
+ INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }}
+ INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }}
+ INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }}
steps:
- uses: actions/checkout@v2
diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml
new file mode 100644
index 0000000000..fdb2e1ddc4
--- /dev/null
+++ b/.github/workflows/tugboat.yml
@@ -0,0 +1,110 @@
+name: Tugboat E2E Tests
+on: deployment_status
+jobs:
+ set-pending:
+ if: github.event.deployment_status.state != 'success' && github.event.deployment_status.state != 'failed'
+ name: Set pending waiting for Tugboat
+ runs-on: ubuntu-latest
+ steps:
+ # Set an initial commit status message to indicate that the tests are
+ # running.
+ - name: set pending status
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}}
+ debug: true
+ script: |
+ return github.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.sha,
+ state: 'pending',
+ context: 'Backstage Tugboat E2E Tests',
+ description: 'Waiting for Tugboat to complete deployment',
+ target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}"
+ });
+
+ run-tests:
+ # Only run after a successful Tugboat deployment.
+ if: github.event.deployment_status.state == 'success'
+ name: Run tests against Tugboat deployment
+ runs-on: ubuntu-latest
+ steps:
+ # Set an initial commit status message to indicate that the tests are
+ # running.
+ - name: set pending status
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}}
+ debug: true
+ script: |
+ return github.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.sha,
+ state: 'pending',
+ context: 'Backstage Tugboat E2E Tests',
+ description: 'Running against tugboat preview',
+ target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}"
+ });
+
+ - uses: actions/checkout@v1
+ - uses: actions/setup-node@v1
+ with:
+ node-version: '14'
+
+ # This is required because the environment_url param that Tugboat uses
+ # to tell us where the preview is located isn't supported unless you
+ # specify the custom Accept header when getting the deployment_status,
+ # and GitHub actions doesn't do that by default. So instead we have to
+ # load the status object manually and get the data we need.
+ # https://developer.github.com/changes/2016-04-06-deployment-and-deployment-status-enhancements/
+ - name: get deployment status
+ id: get-status-env
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}}
+ result-encoding: string
+ script: |
+ const result = await github.repos.getDeploymentStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ deployment_id: context.payload.deployment.id,
+ status_id: context.payload.deployment_status.id,
+ headers: {
+ 'Accept': 'application/vnd.github.ant-man-preview+json'
+ },
+ });
+ console.log(result);
+ return result.data.environment_url;
+ - name: echo tugboat preview url
+ run: |
+ curl ${{steps.get-status-env.outputs.result}}
+ - name: set status
+ if: ${{ failure() }}
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}}
+ script: |
+ return github.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.sha,
+ state: "error",
+ context: 'Backstage Tugboat E2E Tests',
+ target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}"
+ });
+ - name: set status
+ if: ${{ success() }}
+ uses: actions/github-script@v3
+ with:
+ github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}}
+ script: |
+ return github.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.sha,
+ state: "success",
+ context: 'Backstage Tugboat E2E Tests',
+ target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}"
+ });
diff --git a/.tugboat/config.yml b/.tugboat/config.yml
new file mode 100644
index 0000000000..b8c3c127c8
--- /dev/null
+++ b/.tugboat/config.yml
@@ -0,0 +1,17 @@
+services:
+ backstage:
+ image: tugboatqa/node:lts
+ expose: 7000
+ default: true
+ commands:
+ init:
+ - mkdir -p /etc/service/node
+ - echo "#!/bin/sh" > /etc/service/node/run
+ - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run
+ - chmod +x /etc/service/node/run
+ build:
+ - yarn install --frozen-lockfile
+ - 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
diff --git a/.tugboat/tugboat.app-config.production.yaml b/.tugboat/tugboat.app-config.production.yaml
new file mode 100644
index 0000000000..f606574d89
--- /dev/null
+++ b/.tugboat/tugboat.app-config.production.yaml
@@ -0,0 +1,13 @@
+app:
+ title: Backstage Tugboat Preview
+ baseUrl:
+ $env: TUGBOAT_DEFAULT_SERVICE_URL
+
+backend:
+ baseUrl:
+ $env: TUGBOAT_DEFAULT_SERVICE_URL
+ cors:
+ origin:
+ $env: TUGBOAT_DEFAULT_SERVICE_URL
+ methods: [GET, POST, PUT, DELETE]
+ credentials: true
diff --git a/app-config.yaml b/app-config.yaml
index c150ef5902..5240a050a8 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -74,13 +74,11 @@ organization:
# Reference documentation http://backstage.io/docs/features/techdocs/configuration
techdocs:
- requestUrl: http://localhost:7000/api/techdocs
- storageUrl: http://localhost:7000/api/techdocs/static/docs
builder: 'local' # Alternatives - 'external'
generators:
techdocs: 'docker' # Alternatives - 'local'
publisher:
- type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives.
+ type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives.
sentry:
organization: my-company
@@ -208,7 +206,7 @@ catalog:
# Backstage example components
- type: file
target: ../catalog-model/examples/all-components.yaml
- # Example component for github-actions
+ # Example component for github-actions and TechDocs
- type: file
target: ../../plugins/github-actions/examples/sample.yaml
# Example component for TechDocs
@@ -243,6 +241,7 @@ scaffolder:
baseUrl: https://gitlab.com
token:
$env: GITLAB_TOKEN
+ visibility: public # or 'internal' or 'private'
azure:
baseUrl: https://dev.azure.com/{your-organization}
api:
@@ -255,6 +254,7 @@ scaffolder:
$env: BITBUCKET_USERNAME
token:
$env: BITBUCKET_TOKEN
+ visibility: public # or or 'private'
auth:
environment: development
diff --git a/docs/architecture-decisions/adr011-plugin-package-structure.md b/docs/architecture-decisions/adr011-plugin-package-structure.md
new file mode 100644
index 0000000000..9133e5b7c2
--- /dev/null
+++ b/docs/architecture-decisions/adr011-plugin-package-structure.md
@@ -0,0 +1,74 @@
+---
+id: adrs-adr011
+title: ADR011: Plugin Package Structure
+description: Architecture Decision Record (ADR) for Plugin Package Structure
+---
+
+## Context
+
+A core feature of Backstage is the extensibility via plugins. The Backstage
+repository is open for contributions of plugins. Even most of the core features
+are implemented as plugins. A plugin consists of one or multiple packages in the
+`plugins/` directory. Up till now, we have a simple conventions for naming
+plugin packages: Plugins are named `x`, with the option of having a related
+backend plugin called `x-backend` (where `x` is the plugin name, like `catalog`
+or `techdocs`). There is a need for sharing code between the frontend and
+backend of a plugin, between backend plugins, or components and hooks between
+different frontend plugins
+([some examples](https://github.com/backstage/backstage/issues/3655#issuecomment-758166746)).
+This results in emerging plugin packages with shared code, like
+`packages/catalog-client` or `packages/techdocs-common`.
+
+> There is a common phrase in software development:
+> [Naming things is hard](https://martinfowler.com/bliki/TwoHardThings.html)
+
+To keep the contributed plugins consistent, this Architecture Decision Record
+provides rules for naming plugin packages.
+
+## Decision
+
+We will place all plugin related code in the `plugins/` directory. The
+`packages/` directory is reserved for core package of Backstage.
+
+We follow this structure for plugin packages (where `x` is the plugin name, for
+example `catalog` or `techdocs`):
+
+- `x`: Contains the main frontend code of the plugin.
+- `x-backend`: Contains the main backend code of the plugin.
+- `x-react`: Contains shared widgets, hooks and similar that both the plugin
+ itself (`x`) and third-party frontend plugins can depend on.
+- `x-node`: Contains utilities for backends that both the plugin backend itself
+ (`x-backend`) and third-party backend plugins can depend on.
+- `x-common`: An isomorphic package with platform agnostic models, clients, and
+ utilities that all packages above or any third-party plugin package can depend
+ on.
+
+We prefix the package names with `@backstage/plugin-`.
+
+This structure is based on a
+[suggestion in issue #3655](https://github.com/backstage/backstage/issues/3655#issuecomment-758166746).
+
+## Consequences
+
+We will actively migrate existing packages that are part of a plugin to the
+`plugins/` folder. This affects packages like:
+
+- `packages/techdocs-common` which should be moved to `plugins/techdocs-node`
+ and named `@backstage/plugin-techdocs-node`.
+- `packages/catalog-client` which will be part of a future
+ `plugins/catalog-common` and named `@backstage/plugin-catalog-common`.
+- While the new location of `packages/catalog-model` should be
+ `plugins/catalog-common` we might want to do an exception here, as it's a very
+ central package.
+
+The limited set of rules might not be sufficient in the future. If additional
+packages are required, we will revisit this decision and extend the pattern.
+
+If possible, we will add tools, such as lint rules, to help enforce the package
+names and dependencies between them or CLI commands to generate these packages.
+
+The distinction between core packages and plugins helps us to setup
+[CODEOWNERS](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners)
+in the repository. We can set the code owners for the `packages/` folder to the
+core team and create additional rules (like `plugins/x*`) for plugin
+maintainers.
diff --git a/docs/architecture.drawio b/docs/architecture.drawio
new file mode 100644
index 0000000000..dbe2a5f6fa
--- /dev/null
+++ b/docs/architecture.drawio
@@ -0,0 +1 @@
+7VpLc6M4EP41PiaFBLbh6LxmLlubSqZ2Z+amBQVUBYgScmzPrx9hJIMQGJzEhtTOxUaNnp++bnU3mtm3yfYLQ1n0Fw1wPINWsJ3ZdzMIAfQ88VdIdqXEs2EpCBkJZKVK8Ex+YSm0pHRNApxrFTmlMSeZLvRpmmKfazLEGN3o1V5orI+aoRAbgmcfxab0XxLwqJS6cFnJv2ISRmpksJALTpCqLFeSRyigm5rIvp/Zt4xSXj4l21scF+ApXMp2Dx1vDxNjOOVDGvwk7Nv6n4erTfLNgzcr9/HvH5sru+zlFcVrueBVlsn58p0CId+QJEapKN3I+phxvO2cCDgsT/AC0wRzthNVZANXAiIZARayvKnwPdSJathCRwqR3NPw0HW1bPEgV34CCs4gFBhdpwEu+rEEEJuIcPycIb94uxHkF7KIJ2LcOzAUp+4t6QQP6uA5JnZt0J0NOduEzsAtFMBlnXhIxUX/qerWqXxyFv2EsuFFCQVNvbrfcpzmhKb5aMRyjsPo6CgCZ1xqKSbVMHyia46f8MtEEQSuDqEzNoLgsyPYRkLQpsrnw9CA8IHRVKhyIKSP8Tok6buPy7cgBVtsnHVRG2eSqwOOC1ELfqZz05ucZh6HDyygjh8cFz8wPdPWB+C0COga+I3voPRosM5AAMamoNUN4UQRbPjJYGQOgoWBoAGcOGhXRRgvSn6M8pz4OlYByqM9rhVwODBC+iGwiWHpmvl4wJZzxEJ8rMOOgKUG9LwFaCVjOEacvOqLaENfjvBIiVjeYZu9xjZbjf0rlykbwVrCQPWjGtqNhuWyjYb7vT+s8h10WL6fDmKf2e67VLN94UdRuIZzVb7b1t/e7VRpS/h31Yl4rjcTxapVUVCNzs63oXQblW9LnW/eqXRTNs7R+3EvzD7zRJy6R9F/IF42VgKmV1uciCxF8dSh7D8ZLxx2ms7F6baw1aQdt2iFLXuWQ1DGIxpSsXv3lbTTwvYZ2MnYSm9MW3lg1k63ne81lmA+zFoKwqBdrVpWVMiPzLcxzsI6bVq2Vl88lBP4UMsNB+Sga2px+A5j1d3HooBiEqaFNglGYiYEhaUhPopX8kVCgmCvBKKPrOg52YbFZ6/r8ksTLP+LbhXr9x/CgH0t8L1JKfcjKX+JSfb1zFbtqunvt+SMnBaqw7MZtXnr+TDpoLORj3THTumaIdPkIWy4Ke7IcTs0w4xVRibrmzSV2DWV+LLwfb7MEVhMTYvbHeVpYzgxNbZND1mo8QPyOZW9TxDDiemybWbRDeSGRhkqlBgSZczOHAsobvQGA+VZMFqizr2eL4ED3fJ3sdTY8eY8SiMfA5r0OXMiRV3w+rCs7ttj0jdm/ZafI+tnW7MRydtkWTNqGErXnuzhR8WxDTdq2RPHNr0u7wJxrG1eGjIU508cW23GaHEs+N9uVNellJ1uWj5+Y2bFtRt1U7ZUuOq+sX3/Gw==
\ No newline at end of file
diff --git a/docs/auth/index.md b/docs/auth/index.md
index cf468c7bb7..a8ddf17c6b 100644
--- a/docs/auth/index.md
+++ b/docs/auth/index.md
@@ -86,7 +86,8 @@ to a `guest` identity for all users, without any ID token. To enable sign-in, a
`SignInPage` needs to be configured, which in turn has to supply a user to the
app. The `@backstage/core` package provides a basic sign-in page that allows
both the user and the app developer to choose between a couple of different
-sign-in methods.
+sign-in methods, or to designate a single provider that may also be logged in to
+automatically.
## Further Reading
diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md
index 9e531de655..35a027df62 100644
--- a/docs/features/kubernetes/installation.md
+++ b/docs/features/kubernetes/installation.md
@@ -7,8 +7,8 @@ description: Installing Kubernetes plugin into Backstage
The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when
viewing entities in the software catalog.
-If you haven't setup Backstage already, start
-[here](../../getting-started/index.md).
+If you haven't setup Backstage already, read the
+[Getting Started](../../getting-started/index.md).
## Adding the Kubernetes frontend plugin
@@ -111,7 +111,7 @@ Backstage app.
## Running Backstage locally
Start the frontend and the backend app by
-[running backstage locally](../../getting-started/running-backstage-locally.md).
+[running Backstage locally](../../getting-started/running-backstage-locally.md).
## Configuration
diff --git a/docs/features/search/README.md b/docs/features/search/README.md
index 0b13b47979..cd971d9d41 100644
--- a/docs/features/search/README.md
+++ b/docs/features/search/README.md
@@ -67,7 +67,7 @@ more to come...
- As a contributor I should be able to integrate plugin data to the indexing
process of Backstage Search by using the standardized API.
- As a software engineer I should be able to search for all content (for
- example, entities, metadata, documentation) in backstage search.
+ example, entities, metadata, documentation) in Backstage search.
more to come...
diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md
index 2b173c4b3e..1aab1268cd 100644
--- a/docs/features/software-catalog/descriptor-format.md
+++ b/docs/features/software-catalog/descriptor-format.md
@@ -54,6 +54,11 @@ software catalog API.
"labels": {
"system": "public-websites"
},
+ "links": [{
+ "url": "https://admin.example-org.com",
+ "title": "Admin Dashboard",
+ "icon": "dashboard"
+ }],
"tags": ["java"],
"name": "artist-web",
"uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2"
@@ -81,6 +86,10 @@ metadata:
circleci.com/project-slug: github/example-org/artist-website
tags:
- java
+ links:
+ - url: https://admin.example-org.com
+ title: Admin Dashboard
+ icon: dashboard
spec:
type: website
lifecycle: production
@@ -314,6 +323,34 @@ This field is optional, and currently has no special semantics.
Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters
in total.
+### `links` [optional]
+
+A list of external hyperlinks related to the entity. Links can provide
+additional contextual information that may be located outside of Backstage
+itself. For example, an admin dashboard or external CMS page.
+
+Users may add links to descriptor YAML files to provide additional reference
+information to external content & resources. Links are not intended to drive any
+additional functionality within Backstage, which is best left to `annotations`
+and `labels`. It is recommended to use links only when an equivalent well-known
+`annotation` does not cover a similar use case.
+
+Fields of a link are:
+
+| Field | Type | Description |
+| ------- | ------ | ------------------------------------------------------------------------------------ |
+| `url` | String | [Required] A `url` in a standard `uri` format (e.g. `https://example.com/some/page`) |
+| `title` | String | [Optional] A user friendly display name for the link. |
+| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. |
+
+_NOTE_: The `icon` field value is meant to be a semantic key that will map to a
+specific icon that may be provided by an icon library (e.g. `material-ui`
+icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by
+one of `[-_.]`. Backstage may support some basic icons out of the box, but the
+Backstage integrator will ultimately be left to provide the appropriate icon
+component mappings. A generic fallback icon would be provided if a mapping
+cannot be resolved.
+
## Common to All Kinds: Relations
The `relations` root field is a read-only list of relations, between the current
diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md
index 37e68c4153..28769d77e1 100644
--- a/docs/features/software-templates/extending/create-your-own-templater.md
+++ b/docs/features/software-templates/extending/create-your-own-templater.md
@@ -86,7 +86,7 @@ follows:
_note_ Currently the templaters that we provide are basically Docker action
containers that are run on top of the skeleton folder. This keeps dependencies
-to a minimum for running backstage scaffolder, but you don't _have_ to use
+to a minimum for running Backstage scaffolder, but you don't _have_ to use
Docker. You can `pip install cookiecutter` to run it locally in your backend.
You could create your own templater that spins up an EC2 instance and downloads
the folder and does everything using an AMI if you want. It's entirely up to
diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md
index 1034ffef0f..12ce6e3ed3 100644
--- a/docs/features/software-templates/index.md
+++ b/docs/features/software-templates/index.md
@@ -39,13 +39,12 @@ internally.

After filling in these variables, you'll get some more fields to fill out which
-are required for backstage usage: the owner (which is a `user` in the backstage
-system), the `storePath` (which right now must be a GitHub Organisation or
-GitHub user and a non-existing GitHub repository name in the format
-`organisation/reponame`), and a GitHub team or user account which should be
-granted admin access to the repository.
+are required for Backstage usage: the owner (which is a `user` in the backstage
+system), and the `storePath` which is a destination URL to create for the
+provider, for instance `https://github.com/backstage/my-new-repository`, or
+`https://gitlab.com/myorg/myrepo`.
-
+
### Run!
diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md
index 724b86aed1..8d2fa7727d 100644
--- a/docs/features/software-templates/installation.md
+++ b/docs/features/software-templates/installation.md
@@ -173,7 +173,12 @@ and access to a running Docker daemon. You can create a GitHub access token
docs on creating private GitHub access tokens is available
[here](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token).
Note that the need for private GitHub access tokens will be replaced with GitHub
-Apps integration further down the line.
+Apps integration further down the line by using the existing `integrations`
+config.
+
+> Note: Some of this configuration may already be set up as part of your
+> `app-config.yaml`. We're moving away from the duplicated config for
+> authentication in the `scaffolder` section and using `integrations` instead.
#### GitHub
@@ -187,10 +192,14 @@ by specifying `visibility` option. Valid options are `public`, `private` and
public within the enterprise.
```yaml
+integrations:
+ github:
+ - host: github.com
+ token:
+ $env: GITHUB_TOKEN
+
scaffolder:
github:
- token:
- $env: GITHUB_TOKEN
visibility: public # or 'internal' or 'private'
```
@@ -201,10 +210,9 @@ allows to configure the private access token and the base URL of a GitLab
instance:
```yaml
-scaffolder:
+integrations:
gitlab:
- api:
- baseUrl: https://gitlab.com
+ - host: gitlab.com
token:
$env: GITLAB_TOKEN
```
@@ -218,10 +226,9 @@ will hopefully support on-prem installations as well but that has not been
verified.
```yaml
-scaffolder:
+integrations:
azure:
- baseUrl: https://dev.azure.com/{your-organization}
- api:
+ - host: dev.azure.com
token:
$env: AZURE_TOKEN
```
diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md
index c43d6d978d..f81f41668d 100644
--- a/docs/features/techdocs/README.md
+++ b/docs/features/techdocs/README.md
@@ -108,12 +108,12 @@ providers are used.
| GitLab | Yes ✅ |
| GitLab Enterprise | Yes ✅ |
-| File Storage Provider | Support Status |
-| --------------------------------- | ----------------------------------------------------------------- |
-| Local Filesystem of Backstage app | Yes ✅ |
-| Google Cloud Storage (GCS) | Yes ✅ |
-| Amazon Web Services (AWS) S3 | Yes ✅ |
-| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) |
+| File Storage Provider | Support Status |
+| --------------------------------- | -------------- |
+| Local Filesystem of Backstage app | Yes ✅ |
+| Google Cloud Storage (GCS) | Yes ✅ |
+| Amazon Web Services (AWS) S3 | Yes ✅ |
+| Azure Blob Storage | Yes ✅ |
[Reach out to us](#feedback) if you want to request more platforms.
diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md
index 92cd78b071..8d25b4047f 100644
--- a/docs/features/techdocs/architecture.md
+++ b/docs/features/techdocs/architecture.md
@@ -12,32 +12,31 @@ out-of-the box experience.
> Note: See below for our recommended deployment architecture which takes care
-> of stability, scalability and speed.
+> of stability, scalability and speed. Also look at the
+> [HOW TO migrate guide](how-to-guides.md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach).
When you open a TechDocs site in Backstage, the
[TechDocs Reader](./concepts.md#techdocs-reader) makes a request to
-`techdocs-backend` with the entity ID and the path of the current page you are
-looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to
-render on the page in TechDocs/Backstage.
+`techdocs-backend` plugin with the entity ID and the path of the current page
+you are looking at. In response, it receives the static files (HTML, CSS, JSON,
+etc.) to render on the page in TechDocs/Backstage.
The static files consist of HTML, CSS and Images generated by MkDocs. We remove
all the JavaScript before adding them to Backstage for security reasons. And
-there are some additional techdocs metadata JSON files that TechDocs needs to
-render a site.
+there is an additional `techdocs_metadata.json` file that TechDocs needs to
+render a site. It's important that you use either
+[techdocs-cli](https://github.com/backstage/techdocs-cli) or
+[techdocs-container](https://github.com/backstage/techdocs-container) to
+generate the docs for the expected output.
The TechDocs Reader then applies a list of "Transformers" (see
[Concepts](./concepts.md)) which modify the generated static HTML files for a
number of use cases e.g. Remove certain headers, filter out some HTML tags, etc.
Currently, we use the Backstage server's (or techdocs-backend's) local file
-system to store the generated files. Publishing to an external storage system
-(AWS S3, GCS, etc.) is also possible, but has not been implemented yet.
-
-A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch
-files from its source repository for docs site generation. 1. By using Git
-and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is
-heavily in progress. Please reach out to us on Discord in the #docs-like-code
-channel to talk about it.
+system to store the generated files. However, it's ideal to use an external
+storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in
+[using cloud storage](using-cloud-storage.md).
## Recommended deployment
@@ -49,22 +48,16 @@ The key difference in the recommended deployment approach is where the docs are
built.
We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We
-recommend using a CI/CD pipeline with the repository that has a dedicated
-step/job to generate docs for TechDocs. The generated static files are then
-stored in a cloud storage solution of your choice.
-[Track progress here](https://github.com/backstage/backstage/issues/3096).
+recommend [using a CI/CD pipeline](configuring-ci-cd.md) with the repository
+that has a dedicated step/job to generate docs for TechDocs. The generated
+static files are then [stored in a cloud storage](using-cloud-storage.md)
+solution of your choice.
Similar to how it is done in the Basic setup, the TechDocs Reader requests
`techdocs-backend` plugin for the docs site. `techdocs-backend` then requests
your configured storage solution for the necessary files and returns them to
TechDocs Reader.
-We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to
-generate docs in your CI/CD system.
-[Track progress here.](https://github.com/backstage/backstage/issues/3400) You
-will be able to use `techdocs-cli` to generate docs and publish the generated
-docs site files to your cloud storage system.
-
Note about caching: We have noticed internally that some storage providers can
be quite slow, which is why we are recommending a cache that sits between the
TechDocs Reader and the Storage.
@@ -97,7 +90,7 @@ permissions to publish the generated docs site files.
**Q: Why do you have separate "basic" and "recommended" deployment approaches?**
A: The basic or out-of-the-box setup is what you get when you create a new app
-or do a git clone of the Backstage repository. We want the first experience to
+or do a `git clone` of the Backstage repository. We want the first experience to
_just work magically_ so that you can have your first experience with TechDocs
which is smooth. However, if you decide to deploy Backstage/TechDocs for
production use, the basic setup would work but there are going to be downsides
@@ -109,19 +102,21 @@ and we welcome such "Alternative" ideas from the community.
**Q: Why don't you recommend techdocs-backend local filesystem to serve static
files?**
-A: It would make scaling a Backstage instance harder. Think about the case where
-we have distributed Backstage deployments. Using a separate file storage system
-for TechDocs makes it easier to do some operations like delete a docs site and
-wipe its contents.
+A: It makes scaling a Backstage instance harder. Think about the case where we
+have distributed Backstage deployments (e.g. multiple Kubernetes pods of your
+Backstage app). Using a separate/central file storage system for TechDocs is
+necessary to make sure sites are persistent when the servers/pods are restarted
+and avoid duplicating sites per instance. By having an external storage, we are
+making it easier to do some operations like delete a docs site or wipe its
+contents.
**Q: Why aren't docs built on the fly i.e. when users visits a page, generate
docs site in real-time?**
-A: Generating the content from Markdown on the fly is not optimal (although that
-is how the basic out-of-the-box setup is implemented). Storage solutions act as
-a cache for the generated static content. TechDocs is also currently built on
-MkDocs which does not allow us to generate docs per-page, so we would have to
-build all docs for a entity on every request.
+A: Generating the content from Markdown on the fly is not optimal. Storage
+solutions act as a cache for the generated static content. TechDocs is also
+currently built on MkDocs which does not allow us to generate docs per-page, so
+we would have to build all docs for a entity on every request.
**Q. Can you use the techdocs plugin without the techdocs-backend plugin?**
@@ -146,21 +141,5 @@ We currently depend on MkDocs to parse doc sites written in Markdown. And we
store the generated static assets and re-use it later to render in Backstage. A
better (futuristic) approach will be to directly parse whatever type of source
files you have in your docs repository and directly render in Backstage in
-real-time.
-
-# Features status
-
-Status of all the features mentioned above.
-
-**In place ✅**
-
-- Basic setup with techdocs-backend file server as storage.
-- Basic setup with cloud storage solution.
-- `techdocs-cli` is able to generate docs in CI/CD environment.
-- `techdocs-cli` is able to publish docs site to any storage.
-
-**Work in progress 🚧**
-
-**Not implemented yet ❌**
-
-- `techdocs-backend` integration with Backstage access control management.
+real-time. You can read more in this
+[RFC - Simplifying TechDocs Frontend Architecture](https://github.com/backstage/backstage/issues/3998).
diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md
index 1580abe69e..20e436d8a4 100644
--- a/docs/features/techdocs/configuration.md
+++ b/docs/features/techdocs/configuration.md
@@ -13,15 +13,6 @@ configuration options for TechDocs.
# File: app-config.yaml
techdocs:
- # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc.
-
- requestUrl: http://localhost:7000/api/techdocs
-
- # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware
- # to serve files from either a local directory or an External storage provider.
-
- storageUrl: http://localhost:7000/api/techdocs/static/docs
-
# generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to
# spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of).
# You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running
@@ -44,7 +35,7 @@ techdocs:
# or you want to use External storage providers like Google Cloud Storage, AWS S3, etc.
publisher:
- # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future).
+ # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'.
# When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files.
# When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files.
# When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files.
@@ -84,4 +75,32 @@ techdocs:
# https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html
region:
$env: AWS_REGION
+
+ # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise.
+
+ azureBlobStorage:
+ # (Required) Azure Blob Storage Container Name
+ containerName: 'techdocs-storage'
+
+ # (Required) An account name is required to write to a storage blob container.
+ # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key
+ credentials:
+ accountName:
+ $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME
+ # (Optional) An account key is required to write to a storage container.
+ # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used.
+ # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json
+ accountKey:
+ $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY
+
+ # (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
+
+ # (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
```
diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md
index 187fe97aed..c15dc1fe69 100644
--- a/docs/features/techdocs/creating-and-publishing.md
+++ b/docs/features/techdocs/creating-and-publishing.md
@@ -66,9 +66,16 @@ Update your component's entity description by adding the following lines to its
```yaml
metadata:
annotations:
- backstage.io/techdocs-ref: dir:./
+ backstage.io/techdocs-ref: url:https://github.com/org/repo
+ # Or
+ # backstage.io/techdocs-ref: url:https://github.com/org/repo/tree/branchName/subFolder
```
+The
+[`backstage.io/techdocs-ref` annotation](../software-catalog/well-known-annotations.md#backstageiotechdocs-ref)
+is used by TechDocs to download the documentation source files for generating an
+Entity's TechDocs site.
+
Create a `/docs` folder in the root of the project with at least an `index.md`
file. _(If you add more markdown files, make sure to update the nav in the
mkdocs.yml file to get a proper navigation for your documentation.)_
diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md
index e1ddfd23f9..e0b323857d 100644
--- a/docs/features/techdocs/getting-started.md
+++ b/docs/features/techdocs/getting-started.md
@@ -150,26 +150,10 @@ app. Now let us tweak some configurations to suit your needs.
**See [TechDocs Configuration Options](configuration.md) for complete
configuration reference.**
-### Setting TechDocs URLs
-
-```yaml
-techdocs:
- storageUrl: http://localhost:7000/api/techdocs/static/docs
- requestUrl: http://localhost:7000/api/techdocs/
-```
-
-`requestUrl` is used by TechDocs frontend plugin to discover techdocs-backend
-endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which
-acts as a middleware between TechDocs and the storage (where the static
-generated docs site are stored). These default values should mostly work for
-you. These options will soon be optional to set.
-
### Should TechDocs Backend generate docs?
```yaml
techdocs:
- storageUrl: http://localhost:7000/api/techdocs/static/docs
- requestUrl: http://localhost:7000/api/techdocs/
builder: 'local'
```
@@ -180,8 +164,8 @@ pipeline, and are being stored in a storage somewhere.
When `techdocs.builder` is set to `'external'`, TechDocs becomes more or less a
read-only experience where it serves static files from a storage containing all
-the generated documentation. Read more in the "Basic" and "Recommended" setup of
-TechDocs [here](architecture.md)
+the generated documentation. Read more in the "Basic" and "Recommended" sections
+of the [TechDocs Architecture](architecture.md).
### Choosing storage (publisher)
@@ -190,14 +174,12 @@ fetch the sites from. This is managed by a
[Publisher](./concepts.md#techdocs-publisher). Examples: Google Cloud Storage,
Amazon S3, or local filesystem of Backstage server.
-It is okay to use the local filesystem in a "Basic" setup when you are trying
-out Backstage for the first time. Using Cloud Storage is documented
-[here](./using-cloud-storage.md).
+It is okay to use the local filesystem in a "basic" setup when you are trying
+out Backstage for the first time. At a later time, review
+[Using Cloud Storage](./using-cloud-storage.md).
```yaml
techdocs:
- storageUrl: http://localhost:7000/api/techdocs/static/docs
- requestUrl: http://localhost:7000/api/techdocs/
builder: 'local'
publisher:
type: 'local'
@@ -219,6 +201,9 @@ no config is provided.
```yaml
techdocs:
+ builder: 'local'
+ publisher:
+ type: 'local'
generators:
techdocs: local
```
@@ -229,23 +214,23 @@ environment is compatible with techdocs.
You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from
pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g.
apt). See our
-[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile)
-for the latest requirements. You should be trying to match your Dockerfile with
-this one.
+[`Dockerfile`](https://github.com/backstage/techdocs-container/blob/main/Dockerfile)
+for the latest requirements. You should be trying to match your `Dockerfile`
+with this one.
Note: We recommend Python version 3.7 or higher.
-Caveat: Please install the `mkdocs-techdocs-core` package after all other Python
-packages. The order is important to make sure we get correct version of some of
-the dependencies. For example, we want `Markdown` version to be
-[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
-You can also explicitly install `Markdown==3.2.2` after installing all other
-Python packages.
+> Caveat: Please install the `mkdocs-techdocs-core` package after all other
+> Python packages. The order is important to make sure we get correct version of
+> some of the dependencies. For example, we want `Markdown` version to be
+> [3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
+> You can also explicitly install `Markdown==3.2.2` after installing all other
+> Python packages.
## Running Backstage locally
Start the frontend and the backend app by
-[running backstage locally](../../getting-started/running-backstage-locally.md).
+[running Backstage locally](../../getting-started/running-backstage-locally.md).
Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/)
to see all your documentation sites.
diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md
index b32ff40589..2cb769b0e3 100644
--- a/docs/features/techdocs/how-to-guides.md
+++ b/docs/features/techdocs/how-to-guides.md
@@ -5,6 +5,36 @@ sidebar_label: "HOW TO" guides
description: TechDocs "HOW TO" guides related to TechDocs
---
+## How to migrate from TechDocs Basic to Recommended deployment approach?
+
+The main difference between TechDocs Basic and Recommended deployment approach
+is where the docs are generated and stored. In Basic or the out-of-the-box
+setup, docs are generated and stored at the server running your Backstage
+instance. But the recommended setup is to generate docs on CI/CD and store the
+generated sites to an external storage (e.g. AWS S3 or GCS). TechDocs in your
+Backstage instance should turn into read-only mode. Read more details and the
+benefits in the [TechDocs Architecture](architecture.md).
+
+Here are the steps needed to switch from the Basic to Recommended setup -
+
+### 1. Prepare a cloud storage
+
+Choose a cloud storage provider like AWS, Google Cloud or Microsoft Azure.
+Follow the detailed instructions for
+[using cloud storage](using-cloud-storage.md) in TechDocs.
+
+### 2. Publish to storage from CI/CD
+
+Start publishing your TechDocs sites from the CI/CD workflow of each repository
+containing the source markdown files. Read the detailed instructions for
+[configuring CI/CD](configuring-ci-cd.md).
+
+### 3. Switch TechDocs to read-only mode
+
+In your Backstage instance's `app-config.yaml`, set `techdocs.builder` from
+`'local'` to `'external'`. By doing this, TechDocs will not try to generate
+docs. Look at [TechDocs configuration](configuration.md) for reference.
+
## How to use URL Reader in TechDocs Prepare step?
If TechDocs is configured to generate docs, it will first download the
diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md
index 21206dede5..de2d505c87 100644
--- a/docs/features/techdocs/using-cloud-storage.md
+++ b/docs/features/techdocs/using-cloud-storage.md
@@ -195,3 +195,94 @@ Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read
the static generated documentation files. When you start the backend of the app,
you should be able to see
`techdocs info Successfully connected to the AWS S3 bucket` in the logs.
+
+## Configuring Azure Blob Storage Container with TechDocs
+
+Follow the
+[official Azure Blob Storage documentation](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json)
+for the latest instructions on the following steps involving Azure Blob Storage.
+
+**1. Set `techdocs.publisher.type` config in your `app-config.yaml`**
+
+Set `techdocs.publisher.type` to `'azureBlobStorage'`.
+
+```yaml
+techdocs:
+ publisher:
+ type: 'azureBlobStorage'
+```
+
+**2. Create an Azure Blob Storage Container**
+
+Create a dedicated container for TechDocs sites.
+[Refer to the official documentation](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal).
+
+TechDocs will publish documentation to this container and will fetch files from
+here to serve documentation in Backstage. Note that the container names are
+globally unique.
+
+Set the config `techdocs.publisher.azureBlobStorage.containerName` in your
+`app-config.yaml` to the name of the container you just created.
+
+```yaml
+techdocs:
+ publisher:
+ type: 'azureBlobStorage'
+ azureBlobStorage:
+ containerName: 'name-of-techdocs-storage-container'
+```
+
+**3a. (Recommended) Authentication using environment variable**
+
+Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in
+your `app-config.yaml` to the your account name.
+
+The storage blob client will automatically use the environment variable
+`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with
+Azure Blob Storage.
+[Steps to create the service where the variables can be retrieved from](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal).
+
+https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad for more
+details.
+
+```yaml
+techdocs:
+ publisher:
+ type: 'azureBlobStorage'
+ azureBlobStorage:
+ containerName: 'name-of-techdocs-storage-bucket'
+ credentials:
+ accountName:
+ $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME
+```
+
+**3b. Authentication using app-config.yaml**
+
+If you do not prefer (3a) and optionally like to use a service account, you can
+follow these steps.
+
+To get credentials, access the Azure Portal and go to "Settings > Access Keys",
+and get your Storage account name and Primary Key.
+https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key
+for more details.
+
+```yaml
+techdocs:
+ publisher:
+ type: 'azureBlobStorage'
+ azureBlobStorage:
+ containerName: 'name-of-techdocs-storage-bucket'
+ credentials:
+ accountName:
+ $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME
+ accountKey:
+ $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY
+```
+
+**4. That's it!**
+
+Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store
+and read the static generated documentation files. When you start the backend of
+the app, you should be able to see
+`techdocs info Successfully connected to the Azure Blob Storage container` in
+the logs.
diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md
index 65d9947dca..418a6b3dd3 100644
--- a/docs/getting-started/deployment-other.md
+++ b/docs/getting-started/deployment-other.md
@@ -4,19 +4,97 @@ title: Other
description: Documentation on different ways of Deployment
---
-## Deploying Locally
+## Docker
-### Try on Docker
+Here we have an example Dockerfile that you can use to build everything together
+in one container. This Dockerfile uses multi-stage builds, and a
+`backend:bundle` command from the CLI.
-Run the following commands if you have Docker environment
+It also provides caching on the `yarn install`'s so that you don't have to do it
+unless absolutely necessary.
-```bash
-$ yarn install
-$ yarn docker-build
-$ docker run --rm -it -p 7000:7000 -e NODE_ENV=development example-backend:latest
+> Note: This Dockerfile assumes that you're running SQLite, or your
+> configuration is setup to connect to an external PostgreSQL Database.
+
+```Dockerfile
+# Stage 1 - Create yarn install skeleton layer
+FROM node:14-buster AS packages
+
+WORKDIR /app
+COPY package.json yarn.lock ./
+
+COPY packages packages
+
+# Uncomment this line if you have a local plugins folder
+# COPY plugins plugins
+
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
+# Stage 2 - Install dependencies and build packages
+FROM node:14-buster AS build
+
+WORKDIR /app
+COPY --from=packages /app .
+
+RUN yarn install --network-timeout 600000 && rm -rf "$(yarn cache dir)"
+
+COPY . .
+
+RUN yarn tsc
+RUN yarn --cwd packages/backend backstage-cli backend:bundle --build-dependencies
+
+# Stage 3 - Build the actual backend image and install production dependencies
+FROM node:14-buster
+
+WORKDIR /app
+
+# Copy from build stage
+COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/skeleton.tar.gz ./
+RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz
+
+RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)"
+
+COPY --from=build /app/packages/backend/dist/bundle.tar.gz .
+RUN tar xzf bundle.tar.gz && rm bundle.tar.gz
+
+COPY app-config.yaml app-config.production.yaml ./
+
+CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"]
```
-Then open http://localhost:7000 on your browser.
+Before building you should also include a `.dockerignore`. This will greatly
+improve the context boot up time of Docker as we are no longer sending all of
+the `node_modules` into the context. It also helps us avoid some limitations and
+errors that may occur when trying to share the `node_modules` folder to inside
+the build.
+
+You can add the following contents to the root of your repository at
+`.dockerignore` and it might look something like the following:
+
+```dockerignore
+.git
+node_modules
+packages/*/node_modules
+plugins/*/node_modules
+plugins/*/dist
+```
+
+Once you have added both the `Dockerfile` and `.dockerignore` to the root of
+your project, and run the following to build the container under a specified
+tag.
+
+```sh
+$ docker build -t example-deployment .
+```
+
+To run the image locally you can run:
+
+```sh
+$ docker run -p -it 7000:7000 example-deployment
+```
+
+You should then start to get logs in your terminal, and then you can open your
+browser at `http://localhost:7000`
## Heroku
diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md
index b5ff3885b3..01fd7bdc24 100644
--- a/docs/getting-started/development-environment.md
+++ b/docs/getting-started/development-environment.md
@@ -31,7 +31,7 @@ $ yarn start
This should open a local instance of Backstage in your browser, otherwise open
one of the URLs printed in the terminal.
-By default, backstage will start on port 3000, however you can override this by
+By default, Backstage will start on port 3000, however you can override this by
setting an environment variable `PORT` on your local machine. e.g.
`export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`.
diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md
index bcb86024fd..4ccd48f17e 100644
--- a/docs/plugins/create-a-plugin.md
+++ b/docs/plugins/create-a-plugin.md
@@ -9,8 +9,9 @@ A Backstage Plugin adds functionality to Backstage.
## Create a Plugin
To create a new plugin, make sure you've run `yarn install` and installed
-dependencies, then run the following on your command line (invoking the
-`backstage-cli`).
+dependencies, then run the following on your command line (a shortcut to
+invoking the [`backstage-cli create-plugin`](../cli/commands.md#create-plugin))
+from the root of your project.
```bash
yarn create-plugin
diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md
index 51d2331140..01de779635 100644
--- a/docs/plugins/integrating-plugin-into-service-catalog.md
+++ b/docs/plugins/integrating-plugin-into-service-catalog.md
@@ -59,7 +59,7 @@ accordingly inside `Router.tsx`)
### Import and use router in the APP
In the `app/src/components/catalog/EntityPage.tsx` (app === your folder,
-containing backstage app) import your created Router:
+containing Backstage app) import your created Router:
```tsx
import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin;
diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md
new file mode 100644
index 0000000000..8b6668606f
--- /dev/null
+++ b/docs/plugins/observability.md
@@ -0,0 +1,54 @@
+---
+id: observability
+title: Observability
+# prettier-ignore
+description: Adding Observability to Your Plugin
+---
+
+This article briefly describes the observability options that are available to a
+Backstage integrator.
+
+## Google Analytics
+
+There is a basic Google Analytics integration built into Backstage. You can
+enable it by adding the following to your app configuration:
+
+```yaml
+app:
+ googleAnalyticsTrackingId: UA-000000-0
+```
+
+Replace the tracking ID with your own.
+
+For more information, learn about Google Analytics
+[here](https://marketingplatform.google.com/about/analytics/).
+
+## Logging
+
+The backend supplies a central [winston](https://github.com/winstonjs/winston)
+root logger that plugins are expected to use for their logging needs. In the
+default production setup, it emits structured JSON logs on stdout, with a field
+`"service": "backstage"` and also tagged on a per-plugin basis. Plugins that
+want to more finely specify what part of their processes that emitted the log
+message should add a `"component"` field to do so.
+
+An example log line could look as follows:
+
+```json
+{
+ "service": "backstage",
+ "type": "plugin",
+ "plugin": "catalog",
+ "component": "catalog-all-locations-refresh",
+ "level": "info",
+ "message": "Locations Refresh: Refreshing location bootstrap:bootstrap"
+}
+```
+
+## Health Checks
+
+The example backend in the Backstage repository
+[supplies](https://github.com/backstage/backstage/blob/bc18571b7a742863a770b2a54e785d6bbef7e184/packages/backend/src/index.ts#L99)
+a very basic health check endpoint on the `/healthcheck` route. You may add such
+a handler to your backend as well, and supply your own logic to it that fits
+your particular health checking needs.
diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md
index 109f6ed6ba..bfeed58c8f 100644
--- a/docs/plugins/plugin-development.md
+++ b/docs/plugins/plugin-development.md
@@ -23,35 +23,60 @@ browser APIs or by depending on external modules to do the work.
### Routing
-Each plugin is responsible for registering its components to corresponding
-routes in the app.
+Each plugin can export routable extensions, which are then imported into the app
+and mounted at a path.
-The app will call the `createPlugin` method on each plugin, passing in a
-`router` object with a set of methods on it.
+First you will need a `RouteRef` instance to serve as the mount point of your
+extensions. This can be used within your own plugin to create a link to the
+extension page using `useRouteRef`, as well as for other plugins to link to your
+extension.
-```jsx
+It is best to place these in a separate top-level `src/routes.ts` file, in order
+to avoid import cycles, for example like this:
+
+```tsx
+/* src/routes.ts */
+import { createRouteRef } from '@backstage/core';
+
+// Note: This route ref is for internal use only, don't export it from the plugin
+export const rootRouteRef = createRouteRef({
+ title: 'Example Page',
+});
+```
+
+Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our
+plugin instance with `createPlugin`, as well as create and wrap our routable
+extension using `createRoutableExtension` from `@backstage/core`:
+
+```tsx
+/* src/plugin.ts */
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
-export const rootRouteRef = createRouteRef({
- path: '/new-plugin',
- title: 'New plugin',
-});
-
-export const plugin = createPlugin({
- id: 'new-plugin',
- register({ router }) {
- router.addRoute(rootRouteRef, ExampleComponent);
+// Create a plugin instance and export this from your plugin package
+export const examplePlugin = createPlugin({
+ id: 'example',
+ routes: {
+ root: rootRouteRef, // This is where the route ref should be exported for usage in the app
},
});
+
+// This creates a routable extension, which are typically full pages of content.
+// Each extension should also be exported from your plugin package.
+export const ExamplePage = examplePlugin.provide(
+ createRoutableExtension({
+ // 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),
+ // This binds the extension to this route ref, which allows for routing within and across plugin extensions
+ mountPoint: rootRouteRef,
+ }),
+);
```
-#### `router` API
+This extension can then be imported and used in the app as follow, typically
+placed within the top-level ``:
-```typescript
-addRoute(
- target: RouteRef,
- Component: ComponentType,
- options?: RouteOptions,
-): void;
+```tsx
+} />
```
diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md
index 3f631a9a46..2aacd02cf2 100644
--- a/docs/plugins/structure-of-a-plugin.md
+++ b/docs/plugins/structure-of-a-plugin.md
@@ -28,6 +28,7 @@ new-plugin/
index.ts
plugin.test.ts
plugin.ts
+ routes.ts
jest.config.js
jest.setup.ts
package.json
@@ -56,26 +57,30 @@ package.json to declare the plugin dependencies, metadata and scripts.
In the `src` folder we get to the interesting bits. Check out the `plugin.ts`:
```jsx
-import { createPlugin, createRouteRef } from '@backstage/core';
-import ExampleComponent from './components/ExampleComponent';
+import { createPlugin, createRoutableExtension } from '@backstage/core';
-export const rootRouteRef = createRouteRef({
- path: '/new-plugin',
- title: 'New plugin',
-});
+import { rootRouteRef } from './routes';
-export const plugin = createPlugin({
- id: 'new-plugin',
- register({ router }) {
- router.addRoute(rootRouteRef, ExampleComponent);
+export const examplePlugin = createPlugin({
+ id: 'example',
+ routes: {
+ root: rootRouteRef,
},
});
+
+export const ExamplePage = examplePlugin.provide(
+ createRoutableExtension({
+ component: () =>
+ import('./components/ExampleComponent').then(m => m.ExampleComponent),
+ mountPoint: rootRouteRef,
+ }),
+);
```
-This is where the plugin is created and where it hooks into the app by declaring
-what component should be shown on what URL. See reference docs for
-[createPlugin](../reference/createPlugin.md) or
-[router](../reference/createPlugin-router.md).
+This is where the plugin is created and where it creates and exports extensions
+that can be imported and used the app. See reference docs for
+[createPlugin](../reference/createPlugin.md) or introduction to the new
+[Composability System](./composability.md).
## Components
@@ -91,12 +96,15 @@ You may tweak these components, rename them and/or replace them completely.
## Connecting the plugin to the Backstage app
-There are two things needed for a Backstage app to start making use of a plugin.
+There are three things needed for a Backstage app to start making use of a
+plugin.
1. Add plugin as dependency in `app/package.json`
2. `import` plugin in `app/src/plugins.ts`
+3. Import and use one or more plugin extensions, for example in
+ `app/src/App.tsx`.
-Luckily these two steps happen automatically when you create a plugin with the
+Luckily these three steps happen automatically when you create a plugin with the
Backstage CLI.
## Talking to the outside world
diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md
deleted file mode 100644
index 0ef5bdbd0f..0000000000
--- a/docs/reference/createPlugin-router.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-id: createPlugin-router
-title: createPlugin - router
-description: Documentation on createPlugin - router
----
-
-The router that is passed to the `register` function makes it possible for
-plugins to hook into routing of the Backstage app and provide the end users with
-new views to navigate to. This is done by utilising the following methods on the
-`router`:
-
-```typescript
-addRoute(
- target: RouteRef,
- Component: ComponentType,
- options?: RouteOptions,
-): void;
-```
-
-## RouteRef
-
-`addRoute` method is using mutable RouteRefs, which can be created as following:
-
-```ts
-import { createRouteRef } from '@backstage/core';
-
-const myPluginRouteRef = createRouteRef({
- path: '/my-plugin',
- title: 'My Plugin',
-});
-```
diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md
index 4a58a5ecdc..e3df66fa12 100644
--- a/docs/reference/createPlugin.md
+++ b/docs/reference/createPlugin.md
@@ -17,32 +17,24 @@ type PluginConfig = {
};
type PluginHooks = {
- router: RouterHooks;
+ featureFlags: FeatureFlagsHooks;
};
```
-- [Read more about the router here](createPlugin-router.md)
- [Read more about feature flags here](createPlugin-feature-flags.md)
## Example Uses
### Creating a basic plugin
-Showcasing adding a route and a feature flag.
+Showcasing adding a feature flag.
```jsx
-import { createPlugin, createRouteRef } from '@backstage/core';
-import ExampleComponent from './components/ExampleComponent';
-
-export const rootRouteRef = createRouteRef({
- path: '/new-plugin',
- title: 'New Plugin',
-});
+import { createPlugin } from '@backstage/core';
export default createPlugin({
id: 'new-plugin',
register({ router, featureFlags }) {
- router.addRoute(rootRouteRef, ExampleComponent);
featureFlags.register('enable-example-component');
},
});
diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md
index dafdb7d55f..69fe5d26ba 100644
--- a/docs/reference/utility-apis/BackstageIdentityApi.md
+++ b/docs/reference/utility-apis/BackstageIdentityApi.md
@@ -79,7 +79,7 @@ Referenced by: [getBackstageIdentity](#getbackstageidentity).
export type BackstageIdentity = {
/**
- * The backstage user ID.
+ * The Backstage user ID.
*/
id: string;
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
index 1c661d274c..d322e2ec3c 100644
--- a/docs/tutorials/quickstart-app-auth.md
+++ b/docs/tutorials/quickstart-app-auth.md
@@ -349,22 +349,18 @@ const app = createApp({
apis,
plugins: Object.values(plugins),
components: {
- SignInPage: props => {
- return (
-
- );
- },
+ SignInPage: props => (
+
+ ),
},
});
```
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index e8d3ab1409..ebce8d900c 100644
--- a/microsite/sidebars.json
+++ b/microsite/sidebars.json
@@ -13,7 +13,6 @@
"Getting Started": [
"getting-started/index",
"getting-started/running-backstage-locally",
- "getting-started/installation",
"getting-started/development-environment",
"getting-started/create-an-app",
{
@@ -130,7 +129,8 @@
"ids": [
"plugins/publishing",
"plugins/publish-private",
- "plugins/add-to-marketplace"
+ "plugins/add-to-marketplace",
+ "plugins/observability"
]
}
],
@@ -188,7 +188,8 @@
"architecture-decisions/adrs-adr007",
"architecture-decisions/adrs-adr008",
"architecture-decisions/adrs-adr009",
- "architecture-decisions/adrs-adr010"
+ "architecture-decisions/adrs-adr010",
+ "architecture-decisions/adrs-adr011"
],
"Contribute": ["../CONTRIBUTING"],
"Support": ["support/support", "support/project-structure"],
diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js
index 8d0c4ea57c..51354343d8 100644
--- a/microsite/siteConfig.js
+++ b/microsite/siteConfig.js
@@ -83,6 +83,7 @@ const siteConfig = {
'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js',
'/js/medium-zoom.js',
'/js/dismissable-banner.js',
+ '/js/scroll-nav-to-view-in-docs.js',
],
// On page navigation for the current documentation page.
diff --git a/microsite/static/js/scroll-nav-to-view-in-docs.js b/microsite/static/js/scroll-nav-to-view-in-docs.js
new file mode 100644
index 0000000000..0e4da26674
--- /dev/null
+++ b/microsite/static/js/scroll-nav-to-view-in-docs.js
@@ -0,0 +1,23 @@
+// On backstage.io/docs pages, scroll the Nav sidebar to focus on
+// the page being viewed. Helpful when the Nav is large enough that
+// the selected page is hidden somewhere at bottom.
+// Credits: https://github.com/facebook/docusaurus/issues/823#issuecomment-421152269
+document.addEventListener('DOMContentLoaded', () => {
+ // Find the active nav item in the sidebar
+ const item = document.getElementsByClassName('navListItemActive')[0];
+ if (!item) {
+ return;
+ }
+ const bounding = item.getBoundingClientRect();
+ if (
+ bounding.top >= 0 &&
+ bounding.bottom <=
+ (window.innerHeight || document.documentElement.clientHeight)
+ ) {
+ // Already visible. Do nothing.
+ } else {
+ // Not visible. Scroll sidebar.
+ item.scrollIntoView({ block: 'start', inline: 'nearest' });
+ document.body.scrollTop = document.documentElement.scrollTop = 0;
+ }
+});
diff --git a/mkdocs.yml b/mkdocs.yml
index a2cbf11b4c..a303fd9978 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,6 +80,7 @@ nav:
- Publishing:
- Open source and npm: 'plugins/publishing.md'
- Private/internal (non-open source): 'plugins/publish-private.md'
+ - Observability: 'plugins/observability.md'
- Configuration:
- Overview: 'conf/index.md'
- Reading Configuration: 'conf/reading.md'
@@ -119,6 +120,7 @@ nav:
- ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md'
- ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md'
- ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md'
+ - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md'
- Contribute: '../CONTRIBUTING.md'
- Support:
- 'support/support.md'
diff --git a/package.json b/package.json
index 758a8ba884..fd130fcbbd 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
},
"version": "1.0.0",
"devDependencies": {
- "@changesets/cli": "^2.11.0",
+ "@changesets/cli": "^2.14.0",
"@octokit/openapi-types": "^2.2.0",
"@spotify/eslint-config-oss": "^1.0.1",
"@spotify/prettier-config": "^9.0.0",
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index d6157f560f..fcd9b795a4 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,102 @@
# example-app
+## 0.2.14
+
+### Patch Changes
+
+- 9d6ef14bc: Migrated to new composability API, exporting the plugin instance as `rollbarPlugin`, the entity page content as `EntityRollbarContent`, and entity conditional as `isRollbarAvailable`. Updated the `EntityPage` for the `example-app` to include a composite `ErrorsSwitcher` component that works with both `Sentry` & `Rollbar`. Also removed the unused and undocumented `RollbarHome` related components.
+- Updated dependencies [ceef4dd89]
+- Updated dependencies [720149854]
+- Updated dependencies [19172f5a9]
+- Updated dependencies [4c6a6dddd]
+- Updated dependencies [398e1f83e]
+- Updated dependencies [87b189d00]
+- Updated dependencies [12ece98cd]
+- Updated dependencies [d82246867]
+- Updated dependencies [7fc89bae2]
+- Updated dependencies [c810082ae]
+- Updated dependencies [b712841d6]
+- Updated dependencies [a5628df40]
+- Updated dependencies [5fa3bdb55]
+- Updated dependencies [bc5082a00]
+- Updated dependencies [6e612ce25]
+- Updated dependencies [e44925723]
+- Updated dependencies [b37501a3d]
+- Updated dependencies [9d6ef14bc]
+- Updated dependencies [025e122c3]
+- Updated dependencies [e9aab60c7]
+- Updated dependencies [21e624ba9]
+- Updated dependencies [0269f4fd9]
+- Updated dependencies [19fe61c27]
+- Updated dependencies [da9f53c60]
+- Updated dependencies [a08c4b0b0]
+- Updated dependencies [bc5082a00]
+- Updated dependencies [bc5082a00]
+- Updated dependencies [b37501a3d]
+- Updated dependencies [90c8f20b9]
+- Updated dependencies [32c95605f]
+- Updated dependencies [7881f2117]
+- Updated dependencies [8dfdec613]
+- Updated dependencies [54c7d02f7]
+- Updated dependencies [de98c32ed]
+- Updated dependencies [806929fe2]
+- Updated dependencies [019fe39a0]
+- Updated dependencies [019fe39a0]
+- Updated dependencies [11cb5ef94]
+ - @backstage/plugin-catalog-import@0.3.7
+ - @backstage/plugin-scaffolder@0.4.2
+ - @backstage/plugin-cost-insights@0.8.0
+ - @backstage/cli@0.6.0
+ - @backstage/plugin-graphiql@0.2.7
+ - @backstage/core@0.6.0
+ - @backstage/plugin-api-docs@0.4.4
+ - @backstage/plugin-catalog@0.3.0
+ - @backstage/plugin-catalog-react@0.0.2
+ - @backstage/plugin-org@0.3.5
+ - @backstage/theme@0.2.3
+ - @backstage/plugin-lighthouse@0.2.9
+ - @backstage/plugin-techdocs@0.5.5
+ - @backstage/plugin-user-settings@0.2.5
+ - @backstage/catalog-model@0.7.1
+ - @backstage/plugin-rollbar@0.2.9
+ - @backstage/plugin-gcp-projects@0.2.4
+ - @backstage/plugin-tech-radar@0.3.4
+ - @backstage/plugin-welcome@0.2.5
+ - @backstage/plugin-explore@0.2.4
+ - @backstage/plugin-circleci@0.2.7
+ - @backstage/plugin-cloudbuild@0.2.8
+ - @backstage/plugin-github-actions@0.3.1
+ - @backstage/plugin-jenkins@0.3.8
+ - @backstage/plugin-kafka@0.2.1
+ - @backstage/plugin-register-component@0.2.8
+ - @backstage/plugin-search@0.2.7
+ - @backstage/plugin-sentry@0.3.4
+ - @backstage/plugin-gitops-profiles@0.2.4
+ - @backstage/plugin-kubernetes@0.3.8
+ - @backstage/plugin-newrelic@0.2.4
+ - @backstage/plugin-pagerduty@0.2.7
+
+## 0.2.13
+
+### Patch Changes
+
+- Updated dependencies [681111228]
+- Updated dependencies [12a56cdfe]
+- Updated dependencies [8b7ef9f8b]
+- Updated dependencies [fac91bcc5]
+- Updated dependencies [9dd057662]
+- Updated dependencies [234e7d985]
+- Updated dependencies [ef7957be4]
+- Updated dependencies [0b1182346]
+- Updated dependencies [a6e3b9596]
+ - @backstage/plugin-kubernetes@0.3.7
+ - @backstage/cli@0.5.0
+ - @backstage/plugin-cost-insights@0.6.0
+ - @backstage/plugin-catalog@0.2.14
+ - @backstage/plugin-catalog-import@0.3.6
+ - @backstage/plugin-scaffolder@0.4.1
+ - @backstage/plugin-kafka@0.2.0
+
## 0.2.12
### Patch Changes
diff --git a/packages/app/package.json b/packages/app/package.json
index 7734dfc244..9b82e0bb32 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,40 +1,41 @@
{
"name": "example-app",
- "version": "0.2.12",
+ "version": "0.2.14",
"private": true,
"bundled": true,
"dependencies": {
- "@backstage/catalog-model": "^0.7.0",
- "@backstage/cli": "^0.4.7",
- "@backstage/core": "^0.5.0",
- "@backstage/plugin-api-docs": "^0.4.3",
- "@backstage/plugin-catalog": "^0.2.12",
- "@backstage/plugin-catalog-import": "^0.3.5",
- "@backstage/plugin-circleci": "^0.2.6",
- "@backstage/plugin-cloudbuild": "^0.2.7",
- "@backstage/plugin-cost-insights": "^0.5.7",
- "@backstage/plugin-explore": "^0.2.3",
- "@backstage/plugin-gcp-projects": "^0.2.3",
- "@backstage/plugin-github-actions": "^0.3.0",
- "@backstage/plugin-gitops-profiles": "^0.2.3",
- "@backstage/plugin-graphiql": "^0.2.6",
- "@backstage/plugin-org": "^0.3.4",
- "@backstage/plugin-jenkins": "^0.3.6",
- "@backstage/plugin-kafka": "^0.1.1",
- "@backstage/plugin-kubernetes": "^0.3.6",
- "@backstage/plugin-lighthouse": "^0.2.8",
- "@backstage/plugin-newrelic": "^0.2.3",
- "@backstage/plugin-pagerduty": "0.2.6",
- "@backstage/plugin-register-component": "^0.2.7",
- "@backstage/plugin-rollbar": "^0.2.8",
- "@backstage/plugin-scaffolder": "^0.4.0",
- "@backstage/plugin-sentry": "^0.3.3",
- "@backstage/plugin-search": "^0.2.6",
- "@backstage/plugin-tech-radar": "^0.3.3",
- "@backstage/plugin-techdocs": "^0.5.4",
- "@backstage/plugin-user-settings": "^0.2.4",
- "@backstage/plugin-welcome": "^0.2.4",
- "@backstage/theme": "^0.2.2",
+ "@backstage/catalog-model": "^0.7.1",
+ "@backstage/cli": "^0.6.0",
+ "@backstage/core": "^0.6.0",
+ "@backstage/plugin-api-docs": "^0.4.4",
+ "@backstage/plugin-catalog": "^0.3.0",
+ "@backstage/plugin-catalog-react": "^0.0.2",
+ "@backstage/plugin-catalog-import": "^0.3.7",
+ "@backstage/plugin-circleci": "^0.2.7",
+ "@backstage/plugin-cloudbuild": "^0.2.8",
+ "@backstage/plugin-cost-insights": "^0.8.0",
+ "@backstage/plugin-explore": "^0.2.4",
+ "@backstage/plugin-gcp-projects": "^0.2.4",
+ "@backstage/plugin-github-actions": "^0.3.1",
+ "@backstage/plugin-gitops-profiles": "^0.2.4",
+ "@backstage/plugin-graphiql": "^0.2.7",
+ "@backstage/plugin-org": "^0.3.5",
+ "@backstage/plugin-jenkins": "^0.3.8",
+ "@backstage/plugin-kafka": "^0.2.1",
+ "@backstage/plugin-kubernetes": "^0.3.8",
+ "@backstage/plugin-lighthouse": "^0.2.9",
+ "@backstage/plugin-newrelic": "^0.2.4",
+ "@backstage/plugin-pagerduty": "0.2.7",
+ "@backstage/plugin-register-component": "^0.2.8",
+ "@backstage/plugin-rollbar": "^0.2.9",
+ "@backstage/plugin-scaffolder": "^0.4.2",
+ "@backstage/plugin-sentry": "^0.3.4",
+ "@backstage/plugin-search": "^0.2.7",
+ "@backstage/plugin-tech-radar": "^0.3.4",
+ "@backstage/plugin-techdocs": "^0.5.5",
+ "@backstage/plugin-user-settings": "^0.2.5",
+ "@backstage/plugin-welcome": "^0.2.5",
+ "@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.0.12",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 1b76773979..00696736d1 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -15,30 +15,30 @@
*/
import {
- createApp,
AlertDisplay,
- OAuthRequestDialog,
- SignInPage,
+ createApp,
createRouteRef,
FlatRoutes,
+ OAuthRequestDialog,
+ SignInPage,
} from '@backstage/core';
-import React from 'react';
-import Root from './components/Root';
-import * as plugins from './plugins';
-import { apis } from './apis';
-import { hot } from 'react-hot-loader/root';
-import { providers } from './identityProviders';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
-import { Router as DocsRouter } from '@backstage/plugin-techdocs';
+import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
+import { ExplorePage } from '@backstage/plugin-explore';
import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
-import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
+import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
+import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
-import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
-import { Route, Navigate } from 'react-router';
-
+import React from 'react';
+import { hot } from 'react-hot-loader/root';
+import { Navigate, Route } from 'react-router';
+import { apis } from './apis';
import { EntityPage } from './components/catalog/EntityPage';
+import Root from './components/Root';
+import { providers } from './identityProviders';
+import * as plugins from './plugins';
const app = createApp({
apis,
@@ -78,6 +78,7 @@ const routes = (
element={}
/>
} />
+ } />
}
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 52dd397418..80706fe459 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -20,6 +20,7 @@ import HomeIcon from '@material-ui/icons/Home';
import ExtensionIcon from '@material-ui/icons/Extension';
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
import MapIcon from '@material-ui/icons/MyLocation';
+import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MoneyIcon from '@material-ui/icons/MonetizationOn';
@@ -82,6 +83,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => (
+
{/* End global nav */}
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index a1ab181211..5474afa72c 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import {
ApiEntity,
Entity,
@@ -27,11 +28,8 @@ import {
ProvidedApisCard,
ProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
-import {
- AboutCard,
- EntityPageLayout,
- useEntity,
-} from '@backstage/plugin-catalog';
+import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog';
+import { useEntity } from '@backstage/plugin-catalog-react';
import {
isPluginApplicableToEntity as isCircleCIAvailable,
Router as CircleCIRouter,
@@ -50,6 +48,7 @@ import {
LatestRunCard as JenkinsLatestRunCard,
Router as JenkinsRouter,
} from '@backstage/plugin-jenkins';
+import { Router as KafkaRouter } from '@backstage/plugin-kafka';
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
import {
EmbeddedRouter as LighthouseRouter,
@@ -57,13 +56,20 @@ import {
LastLighthouseAuditCard,
} from '@backstage/plugin-lighthouse';
import {
- OwnershipCard,
- MembersListCard,
GroupProfileCard,
+ MembersListCard,
+ OwnershipCard,
UserProfileCard,
} from '@backstage/plugin-org';
+import {
+ isPluginApplicableToEntity as isPagerDutyAvailable,
+ PagerDutyCard,
+} from '@backstage/plugin-pagerduty';
+import {
+ isRollbarAvailable,
+ Router as RollbarRouter,
+} from '@backstage/plugin-rollbar';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
-import { Router as KafkaRouter } from '@backstage/plugin-kafka';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
import {
@@ -82,10 +88,6 @@ import {
PullRequestsStatsCard,
Router as PullRequestsRouter,
} from '@roadiehq/backstage-plugin-github-pull-requests';
-import {
- isPluginApplicableToEntity as isPagerDutyAvailable,
- PagerDutyCard,
-} from '@backstage/plugin-pagerduty';
import {
isPluginApplicableToEntity as isTravisCIAvailable,
RecentTravisCIBuildsWidget,
@@ -156,6 +158,15 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
);
};
+export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => {
+ switch (true) {
+ case isRollbarAvailable(entity):
+ return ;
+ default:
+ return ;
+ }
+};
+
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
@@ -215,9 +226,9 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
element={}
/>
}
+ path="/errors/*"
+ title="Errors"
+ element={}
/>
(
element={}
/>
}
+ path="/errors/*"
+ title="Errors"
+ element={}
/>
{
it('can replace the default logger', () => {
@@ -29,4 +29,70 @@ describe('rootLogger', () => {
expect.stringContaining('testing'),
);
});
+
+ describe('createRootLoger', () => {
+ it('creates a new logger', () => {
+ const oldLogger = getRootLogger();
+ const newLogger = createRootLogger();
+
+ expect(oldLogger).not.toBe(newLogger);
+ });
+
+ it('replaces the existing root logger', () => {
+ const oldLogger = getRootLogger();
+ createRootLogger();
+ const newLogger = getRootLogger();
+ expect(oldLogger).not.toBe(newLogger);
+ });
+
+ it('can append additional default metadata', () => {
+ const format = winston.format.json();
+ const logger = createRootLogger({
+ format,
+ defaultMeta: {
+ appName: 'backstage',
+ appEnv: 'prod',
+ containerId: 'abc',
+ },
+ });
+ jest.spyOn(format, 'transform');
+
+ logger.info('testing');
+
+ expect(format.transform).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'testing',
+ service: 'backstage',
+ appName: 'backstage',
+ appEnv: 'prod',
+ containerId: 'abc',
+ }),
+ {},
+ );
+ });
+
+ it('can add override existing transports', () => {
+ const transport = new winston.transports.Console({ level: 'debug' });
+ const logger = createRootLogger({ transports: [transport] });
+ expect(logger.transports.length).toBe(1);
+ expect(logger.transports[0]).toBe(transport);
+ });
+
+ it('can append an additional transport', () => {
+ const logger = createRootLogger();
+ const transport = new winston.transports.Console({ level: 'debug' });
+ logger.add(transport);
+ expect(logger.transports.length).toBe(2);
+ expect(logger.transports[1]).toBe(transport);
+ expect(logger.transports[1].level).toBe('debug');
+ });
+
+ it('can override default format', () => {
+ const format = winston.format(() => false)();
+ const logger = createRootLogger({ format });
+ expect(
+ logger.format.transform({ message: 'hello', level: 'info' }),
+ ).toBeFalsy();
+ });
+ });
});
diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts
index 306ed23444..a8cf4721d6 100644
--- a/packages/backend-common/src/logging/rootLogger.ts
+++ b/packages/backend-common/src/logging/rootLogger.ts
@@ -13,23 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
+import { merge } from 'lodash';
import * as winston from 'winston';
+import { LoggerOptions } from 'winston';
import { coloredFormat } from './formats';
-let rootLogger: winston.Logger = winston.createLogger({
- level: process.env.LOG_LEVEL || 'info',
- format:
- process.env.NODE_ENV === 'production'
- ? winston.format.json()
- : coloredFormat,
- defaultMeta: { service: 'backstage' },
- transports: [
- new winston.transports.Console({
- silent:
- process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL,
- }),
- ],
-});
+let rootLogger: winston.Logger;
export function getRootLogger(): winston.Logger {
return rootLogger;
@@ -38,3 +28,34 @@ export function getRootLogger(): winston.Logger {
export function setRootLogger(newLogger: winston.Logger) {
rootLogger = newLogger;
}
+
+export function createRootLogger(
+ options: winston.LoggerOptions = {},
+ env = process.env,
+): winston.Logger {
+ const logger = winston.createLogger(
+ merge(
+ {
+ level: env.LOG_LEVEL || 'info',
+ format: winston.format.combine(
+ env.NODE_ENV === 'production' ? winston.format.json() : coloredFormat,
+ ),
+ defaultMeta: {
+ service: 'backstage',
+ },
+ transports: [
+ new winston.transports.Console({
+ silent: env.JEST_WORKER_ID !== undefined && !env.LOG_LEVEL,
+ }),
+ ],
+ },
+ options,
+ ),
+ );
+
+ setRootLogger(logger);
+
+ return logger;
+}
+
+rootLogger = createRootLogger();
diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts
index 20f8feba42..c937f8a40c 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.test.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts
@@ -14,7 +14,9 @@
* limitations under the License.
*/
-import fs from 'fs';
+import * as os from 'os';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
import path from 'path';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@@ -31,6 +33,8 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
+const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
+
describe('AzureUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
@@ -139,6 +143,16 @@ describe('AzureUrlReader', () => {
});
describe('readTree', () => {
+ beforeEach(() => {
+ mockFs({
+ [tmpDir]: mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'),
);
@@ -200,6 +214,21 @@ describe('AzureUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files', async () => {
+ const response = await processor.readTree(
+ 'https://dev.azure.com/organization/project/_git/repository',
+ );
+
+ const dir = await response.dir({ targetDir: tmpDir });
+
+ await expect(
+ fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('throws a NotModifiedError when given a etag in options', async () => {
const fnAzure = async () => {
await processor.readTree(
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index 9661368b5e..974c84b2c2 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -16,7 +16,8 @@
import { ConfigReader } from '@backstage/config';
import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'path';
@@ -53,6 +54,16 @@ describe('BitbucketUrlReader', () => {
});
describe('readTree', () => {
+ beforeEach(() => {
+ mockFs({
+ '/tmp': mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
const worker = setupServer();
msw.setupDefaultHandlers(worker);
@@ -126,12 +137,12 @@ describe('BitbucketUrlReader', () => {
),
),
rest.get(
- 'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch',
+ 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
- values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
+ values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
@@ -155,6 +166,21 @@ describe('BitbucketUrlReader', () => {
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
});
+ it('creates a directory with the wanted files', async () => {
+ const response = await bitbucketProcessor.readTree(
+ 'https://bitbucket.org/backstage/mock',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('uses private bitbucket host', async () => {
const response = await hostedBitbucketProcessor.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
@@ -185,6 +211,18 @@ describe('BitbucketUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files with a subpath', async () => {
+ const response = await bitbucketProcessor.readTree(
+ 'https://bitbucket.org/backstage/mock/src/master/docs',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await bitbucketProcessor.readTree(
@@ -204,5 +242,17 @@ describe('BitbucketUrlReader', () => {
expect(response.etag).toBe('12ab34cd56ef');
});
+
+ it('should throw error when apiBaseUrl is missing', () => {
+ expect(() => {
+ /* eslint-disable no-new */
+ new BitbucketUrlReader(
+ {
+ host: 'bitbucket.mycompany.net',
+ },
+ { treeResponseFactory },
+ );
+ }).toThrowError('must configure an explicit apiBaseUrl');
+ });
});
});
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts
index e9727e04cf..373c75588e 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts
@@ -121,31 +121,9 @@ export class BitbucketUrlReader implements UrlReader {
throw new Error(message);
}
- // Get the filename of archive from the header of the response
- const contentDispositionHeader = archiveBitbucketResponse.headers.get(
- 'content-disposition',
- ) as string;
- if (!contentDispositionHeader) {
- throw new Error(
- `Failed to read tree from ${url}. ` +
- 'Bitbucket API response for downloading archive does not contain content-disposition header ',
- );
- }
- const fileNameRegEx = new RegExp(
- /^attachment; filename=(?.*).zip$/,
- );
- const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
- ?.groups?.fileName;
- if (!archiveFileName) {
- throw new Error(
- `Failed to read tree from ${url}. Bitbucket API response for downloading archive has an unexpected ` +
- `format of content-disposition header ${contentDispositionHeader} `,
- );
- }
-
return await this.treeResponseFactory.fromZipArchive({
stream: (archiveBitbucketResponse.body as unknown) as Readable,
- path: `${archiveFileName}/${filepath}`,
+ subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
@@ -161,13 +139,18 @@ export class BitbucketUrlReader implements UrlReader {
}
private async getLastCommitShortHash(url: string): Promise {
- const { name: repoName, owner: project, ref } = parseGitUrl(url);
+ const { resource, name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, this.config);
}
- const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`;
+
+ const isHosted = resource === 'bitbucket.org';
+ // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
+ const commitsApiUrl = isHosted
+ ? `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
+ : `${this.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
@@ -182,14 +165,26 @@ export class BitbucketUrlReader implements UrlReader {
}
const commits = await commitsResponse.json();
- if (
- commits &&
- commits.values &&
- commits.values.length > 0 &&
- commits.values[0].hash
- ) {
- return commits.values[0].hash.substring(0, 12);
+ if (isHosted) {
+ if (
+ commits &&
+ commits.values &&
+ commits.values.length > 0 &&
+ commits.values[0].hash
+ ) {
+ return commits.values[0].hash.substring(0, 12);
+ }
+ } else {
+ if (
+ commits &&
+ commits.values &&
+ commits.values.length > 0 &&
+ commits.values[0].id
+ ) {
+ return commits.values[0].id.substring(0, 12);
+ }
}
+
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts
index 080e8b1d5b..fae3f3ba12 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts
@@ -17,7 +17,8 @@
import { ConfigReader } from '@backstage/config';
import { GithubCredentialsProvider } from '@backstage/integration';
import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'path';
@@ -107,6 +108,16 @@ describe('GithubUrlReader', () => {
});
describe('readTree', () => {
+ beforeEach(() => {
+ mockFs({
+ '/tmp': mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
const repoBuffer = fs.readFileSync(
path.resolve(
'src',
@@ -227,6 +238,21 @@ describe('GithubUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files', async () => {
+ const response = await githubProcessor.readTree(
+ 'https://github.com/backstage/mock',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('should use the headers from the credentials provider to the fetch request', async () => {
expect.assertions(2);
@@ -293,6 +319,18 @@ describe('GithubUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files with subpath', async () => {
+ const response = await githubProcessor.readTree(
+ 'https://github.com/backstage/mock/tree/main/docs',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('throws a NotModifiedError when given a etag in options', async () => {
const fnGithub = async () => {
await githubProcessor.readTree('https://github.com/backstage/mock', {
@@ -338,5 +376,20 @@ describe('GithubUrlReader', () => {
};
await expect(fnGithub).rejects.toThrow(NotFoundError);
});
+
+ it('should throw error when apiBaseUrl is missing', () => {
+ expect(() => {
+ /* eslint-disable no-new */
+ new GithubUrlReader(
+ {
+ host: 'ghe.mycompany.net',
+ },
+ {
+ treeResponseFactory,
+ credentialsProvider: mockCredentialsProvider,
+ },
+ );
+ }).toThrowError('must configure an explicit apiBaseUrl');
+ });
});
});
diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts
index 6c7cefe2ef..4f1aa7c07b 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.ts
@@ -166,37 +166,11 @@ export class GithubUrlReader implements UrlReader {
throw new Error(message);
}
- // Get the filename of archive from the header of the response
- const contentDispositionHeader = archive.headers.get(
- 'content-disposition',
- ) as string;
- if (!contentDispositionHeader) {
- throw new Error(
- `Failed to read tree from ${url}. ` +
- 'GitHub API response for downloading archive does not contain content-disposition header ',
- );
- }
- const fileNameRegEx = new RegExp(
- /^attachment; filename=(?.*).tar.gz$/,
- );
- const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
- ?.groups?.fileName;
- if (!archiveFileName) {
- throw new Error(
- `Failed to read tree from ${url}. GitHub API response for downloading archive has an unexpected ` +
- `format of content-disposition header ${contentDispositionHeader} `,
- );
- }
-
- // The path includes the name of the directory inside the tarball and a sub path
- // if requested in readTree.
- const path = `${archiveFileName}/${filepath}`;
-
return await this.deps.treeResponseFactory.fromTarArchive({
// TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
// to stick to using that in exclusively backend code.
stream: (archive.body as unknown) as Readable,
- path,
+ subpath: filepath,
etag: commitSha,
filter: options?.filter,
});
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
index c0736f769d..b18e7a4294 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -16,7 +16,8 @@
import { ConfigReader } from '@backstage/config';
import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'path';
@@ -153,6 +154,16 @@ describe('GitlabUrlReader', () => {
});
describe('readTree', () => {
+ beforeEach(() => {
+ mockFs({
+ '/tmp': mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
const archiveBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'),
);
@@ -254,6 +265,21 @@ describe('GitlabUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files', async () => {
+ const response = await gitlabProcessor.readTree(
+ 'https://gitlab.com/backstage/mock',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('returns the wanted files from hosted gitlab', async () => {
worker.use(
rest.get(
@@ -296,6 +322,18 @@ describe('GitlabUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
+ it('creates a directory with the wanted files with subpath', async () => {
+ const response = await gitlabProcessor.readTree(
+ 'https://gitlab.com/backstage/mock/tree/main/docs',
+ );
+
+ const dir = await response.dir({ targetDir: '/tmp' });
+
+ await expect(
+ fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
it('throws a NotModifiedError when given a etag in options', async () => {
const fnGitlab = async () => {
await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', {
@@ -341,5 +379,17 @@ describe('GitlabUrlReader', () => {
};
await expect(fnGithub).rejects.toThrow(NotFoundError);
});
+
+ it('should throw error when apiBaseUrl is missing', () => {
+ expect(() => {
+ /* eslint-disable no-new */
+ new GitlabUrlReader(
+ {
+ host: 'gitlab.mycompany.com',
+ },
+ { treeResponseFactory },
+ );
+ }).toThrowError('must configure an explicit apiBaseUrl');
+ });
});
});
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts
index 654f4f9a85..8a763d1259 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.ts
@@ -51,6 +51,12 @@ export class GitlabUrlReader implements UrlReader {
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.treeResponseFactory = deps.treeResponseFactory;
+
+ if (!config.apiBaseUrl) {
+ throw new Error(
+ `GitLab integration for '${config.host}' must configure an explicit apiBaseUrl`,
+ );
+ }
}
async read(url: string): Promise {
@@ -140,33 +146,9 @@ export class GitlabUrlReader implements UrlReader {
throw new Error(message);
}
- // Get the filename of archive from the header of the response
- const contentDispositionHeader = archiveGitLabResponse.headers.get(
- 'content-disposition',
- ) as string;
- if (!contentDispositionHeader) {
- throw new Error(
- `Failed to read tree from ${url}. ` +
- 'GitLab API response for downloading archive does not contain content-disposition header ',
- );
- }
- const fileNameRegEx = new RegExp(
- /^attachment; filename="(?.*).zip"$/,
- );
- const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
- ?.groups?.fileName;
- if (!archiveFileName) {
- throw new Error(
- `Failed to read tree from ${url}. GitLab API response for downloading archive has an unexpected ` +
- `format of content-disposition header ${contentDispositionHeader} `,
- );
- }
-
- const path = filepath ? `${archiveFileName}/${filepath}/` : '';
-
return await this.treeResponseFactory.fromZipArchive({
stream: (archiveGitLabResponse.body as unknown) as Readable,
- path,
+ subpath: filepath,
etag: commitSha,
filter: options?.filter,
});
diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts
new file mode 100644
index 0000000000..18f87636a5
--- /dev/null
+++ b/packages/backend-common/src/reading/integration.test.ts
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ConfigReader } from '@backstage/config';
+import { getVoidLogger } from '../logging';
+import { UrlReaders } from './UrlReaders';
+
+const reader = UrlReaders.default({
+ logger: getVoidLogger(),
+ config: new ConfigReader({
+ // The tokens in this config provide read only access to the backstage-verification repos
+ integrations: {
+ github: [
+ {
+ host: 'github.com',
+ token:
+ process.env.INTEGRATION_TEST_GITHUB_TOKEN ||
+ `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`,
+ },
+ ],
+ gitlab: [
+ {
+ host: 'gitlab.com',
+ token:
+ process.env.INTEGRATION_TEST_GITLAB_TOKEN || 'tveGtSHDBJM9ZRHZNRfm',
+ },
+ ],
+ bitbucket: [
+ {
+ host: 'bitbucket.org',
+ username: 'backstage-verification',
+ appPassword:
+ process.env.INTEGRATION_TEST_BITBUCKET_TOKEN ||
+ 'H79MAAhtbZwCafkVTrrQ',
+ },
+ ],
+ azure: [
+ {
+ host: 'dev.azure.com',
+ // lasts until 2022-01-28
+ token:
+ process.env.INTEGRATION_TEST_AZURE_TOKEN ||
+ `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`,
+ },
+ ],
+ },
+ }),
+});
+
+function withRetries(count: number, fn: () => Promise) {
+ return async () => {
+ let error;
+ for (let i = 0; i < count; i++) {
+ try {
+ await fn();
+ return;
+ } catch (err) {
+ error = err;
+ }
+ }
+ throw error;
+ };
+}
+
+describe('UrlReaders', () => {
+ jest.setTimeout(30_000);
+
+ it(
+ 'should read data from azure',
+ withRetries(3, async () => {
+ const data = await reader.read(
+ 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2Ftemplate.yaml',
+ );
+ expect(data.toString()).toContain('test-template-azure');
+
+ const res = await reader.readTree(
+ 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2F{{cookiecutter.name}}',
+ );
+ const files = await res.files();
+ expect(files).toEqual([
+ {
+ path: 'catalog-info.yaml',
+ content: expect.any(Function),
+ },
+ ]);
+ }),
+ );
+
+ it(
+ 'should read data from gitlab',
+ withRetries(3, async () => {
+ const data = await reader.read(
+ 'https://gitlab.com/backstage-verification/test-templates/-/blob/master/template.yaml',
+ );
+ expect(data.toString()).toContain('test-template-gitlab');
+
+ const res = await reader.readTree(
+ 'https://gitlab.com/backstage-verification/test-templates/-/tree/master/{{cookiecutter.name}}',
+ );
+ const files = await res.files();
+ expect(files).toEqual([
+ {
+ path: 'catalog-info.yaml',
+ content: expect.any(Function),
+ },
+ ]);
+ }),
+ );
+
+ it(
+ 'should read data from bitbucket',
+ withRetries(3, async () => {
+ const data = await reader.read(
+ 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
+ );
+ expect(data.toString()).toContain('test-template-bitbucket');
+
+ const res = await reader.readTree(
+ 'https://bitbucket.org/backstage-verification/test-template/src/master/{{cookiecutter.name}}',
+ );
+ const files = await res.files();
+ expect(files).toEqual([
+ {
+ path: 'catalog-info.yaml',
+ content: expect.any(Function),
+ },
+ ]);
+ }),
+ );
+
+ it(
+ 'should read data from github',
+ withRetries(3, async () => {
+ const data = await reader.read(
+ 'https://github.com/backstage-verification/test-templates/blob/master/template.yaml',
+ );
+ expect(data.toString()).toContain('test-template-github');
+
+ const res = await reader.readTree(
+ 'https://github.com/backstage-verification/test-templates/tree/master/{{cookiecutter.name}}',
+ );
+ const files = await res.files();
+ expect(files).toEqual([
+ {
+ path: 'catalog-info.yaml',
+ content: expect.any(Function),
+ },
+ ]);
+ }),
+ );
+});
diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
index 7332154d09..05ecdb0fc3 100644
--- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
+++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
@@ -24,8 +24,9 @@ import { ZipArchiveResponse } from './ZipArchiveResponse';
type FromArchiveOptions = {
// A binary stream of a tar archive.
stream: Readable;
- // If set, the root of the tree will be set to the given directory path.
- path?: string;
+ // If unset, the files at the root of the tree will be read.
+ // subpath must not contain the name of the top level directory.
+ subpath?: string;
// etag of the blob
etag: string;
// Filter passed on from the ReadTreeOptions
@@ -45,7 +46,7 @@ export class ReadTreeResponseFactory {
async fromTarArchive(options: FromArchiveOptions): Promise {
return new TarArchiveResponse(
options.stream,
- options.path ?? '',
+ options.subpath ?? '',
this.workDir,
options.etag,
options.filter,
@@ -55,7 +56,7 @@ export class ReadTreeResponseFactory {
async fromZipArchive(options: FromArchiveOptions): Promise {
return new ZipArchiveResponse(
options.stream,
- options.path ?? '',
+ options.subpath ?? '',
this.workDir,
options.etag,
options.filter,
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
index 2cbfc4a89e..2b76bea9e3 100644
--- a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
@@ -38,7 +38,7 @@ describe('TarArchiveResponse', () => {
it('should read files', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
- const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+ const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
const files = await res.files();
expect(files).toEqual([
@@ -61,12 +61,8 @@ describe('TarArchiveResponse', () => {
it('should read files with filter', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
- const res = new TarArchiveResponse(
- stream,
- 'mock-main/',
- '/tmp',
- 'etag',
- path => path.endsWith('.yml'),
+ const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
+ path.endsWith('.yml'),
);
const files = await res.files();
@@ -83,7 +79,7 @@ describe('TarArchiveResponse', () => {
it('should read as archive and files', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
- const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+ const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
const buffer = await res.archive();
await expect(res.archive()).rejects.toThrow(
@@ -115,24 +111,18 @@ describe('TarArchiveResponse', () => {
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
const dir = await res.dir();
-
await expect(
- fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
+ fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
- fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
+ fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('should extract archive into directory with a subpath', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
- const res = new TarArchiveResponse(
- stream,
- 'mock-main/docs/',
- '/tmp',
- 'etag',
- );
+ const res = new TarArchiveResponse(stream, 'docs', '/tmp', 'etag');
const dir = await res.dir();
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -144,12 +134,8 @@ describe('TarArchiveResponse', () => {
it('should extract archive into directory with a subpath and filter', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
- const res = new TarArchiveResponse(
- stream,
- 'mock-main/',
- '/tmp',
- 'etag',
- path => path.endsWith('.yml'),
+ const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
+ path.endsWith('.yml'),
);
const dir = await res.dir({ targetDir: '/tmp' });
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
index 5927eb75a1..24bfa3f1a8 100644
--- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
@@ -15,7 +15,7 @@
*/
import tar, { Parse, ParseStream, ReadEntry } from 'tar';
-import path from 'path';
+import platformPath from 'path';
import fs from 'fs-extra';
import { Readable, pipeline as pipelineCb } from 'stream';
import { promisify } from 'util';
@@ -30,6 +30,10 @@ import {
const TarParseStream = (Parse as unknown) as { new (): ParseStream };
const pipeline = promisify(pipelineCb);
+// Matches a directory name + one `/` at the start of any string,
+// containing any character except `/` one or more times, and ending with a `/`
+// e.g. Will match `dirA/` in `dirA/dirB/file.ext`
+const directoryNameRegex = /^[^\/]+\//;
/**
* Wraps a tar archive stream into a tree response reader.
@@ -78,14 +82,18 @@ export class TarArchiveResponse implements ReadTreeResponse {
return;
}
+ // File path relative to the root extracted directory. Will remove the
+ // top level dir name from the path since its name is hard to predetermine.
+ const relativePath = entry.path.replace(directoryNameRegex, '');
+
if (this.subPath) {
- if (!entry.path.startsWith(this.subPath)) {
+ if (!relativePath.startsWith(this.subPath)) {
entry.resume();
return;
}
}
- const path = entry.path.slice(this.subPath.length);
+ const path = relativePath.slice(this.subPath.length);
if (this.filter) {
if (!this.filter(path)) {
entry.resume();
@@ -97,7 +105,10 @@ export class TarArchiveResponse implements ReadTreeResponse {
await pipeline(entry, concatStream(resolve));
});
- files.push({ path, content: () => content });
+ files.push({
+ path,
+ content: () => content,
+ });
entry.resume();
});
@@ -136,9 +147,11 @@ export class TarArchiveResponse implements ReadTreeResponse {
const dir =
options?.targetDir ??
- (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+ (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-')));
- const strip = this.subPath ? this.subPath.split('/').length - 1 : 0;
+ // Equivalent of tar --strip-components=N
+ // When no subPath is given, remove just 1 top level directory
+ const strip = this.subPath ? this.subPath.split('/').length : 1;
await pipeline(
this.stream,
@@ -146,7 +159,10 @@ export class TarArchiveResponse implements ReadTreeResponse {
strip,
cwd: dir,
filter: path => {
- if (this.subPath && !path.startsWith(this.subPath)) {
+ // File path relative to the root extracted directory. Will remove the
+ // top level dir name from the path since its name is hard to predetermine.
+ const relativePath = path.replace(directoryNameRegex, '');
+ if (this.subPath && !relativePath.startsWith(this.subPath)) {
return false;
}
if (this.filter) {
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
index b42ec79d81..3bcaa5e0e3 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
@@ -38,7 +38,7 @@ describe('ZipArchiveResponse', () => {
it('should read files', async () => {
const stream = fs.createReadStream('/test-archive.zip');
- const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+ const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
const files = await res.files();
expect(files).toEqual([
@@ -61,12 +61,8 @@ describe('ZipArchiveResponse', () => {
it('should read files with filter', async () => {
const stream = fs.createReadStream('/test-archive.zip');
- const res = new ZipArchiveResponse(
- stream,
- 'mock-main/',
- '/tmp',
- 'etag',
- path => path.endsWith('.yml'),
+ const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
+ path.endsWith('.yml'),
);
const files = await res.files();
@@ -83,7 +79,7 @@ describe('ZipArchiveResponse', () => {
it('should read as archive and files', async () => {
const stream = fs.createReadStream('/test-archive.zip');
- const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+ const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
const buffer = await res.archive();
await expect(res.archive()).rejects.toThrow(
@@ -117,22 +113,17 @@ describe('ZipArchiveResponse', () => {
const dir = await res.dir();
await expect(
- fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
+ fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
- fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
+ fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('should extract archive into directory with a subpath', async () => {
const stream = fs.createReadStream('/test-archive.zip');
- const res = new ZipArchiveResponse(
- stream,
- 'mock-main/docs/',
- '/tmp',
- 'etag',
- );
+ const res = new ZipArchiveResponse(stream, 'docs/', '/tmp', 'etag');
const dir = await res.dir();
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -144,12 +135,8 @@ describe('ZipArchiveResponse', () => {
it('should extract archive into directory with a subpath and filter', async () => {
const stream = fs.createReadStream('/test-archive.zip');
- const res = new ZipArchiveResponse(
- stream,
- 'mock-main/',
- '/tmp',
- 'etag',
- path => path.endsWith('.yml'),
+ const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
+ path.endsWith('.yml'),
);
const dir = await res.dir({ targetDir: '/tmp' });
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
index 07d34faaa3..323f7b4778 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import path from 'path';
+import platformPath from 'path';
import fs from 'fs-extra';
import unzipper, { Entry } from 'unzipper';
import archiver from 'archiver';
@@ -25,6 +25,11 @@ import {
ReadTreeResponseDirOptions,
} from '../types';
+// Matches a directory name + one `/` at the start of any string,
+// containing any character except / one or more times, and ending with a `/`
+// e.g. Will match `dirA/` in `dirA/dirB/file.ext`
+const directoryNameRegex = /^[^\/]+\//;
+
/**
* Wraps a zip archive stream into a tree response reader.
*/
@@ -60,18 +65,26 @@ export class ZipArchiveResponse implements ReadTreeResponse {
this.read = true;
}
- private getPath(entry: Entry): string {
- return entry.path.slice(this.subPath.length);
+ // Will remove the top level dir name from the path since its name is hard to predetermine.
+ private stripTopDirectory(path: string): string {
+ return path.replace(directoryNameRegex, '');
+ }
+
+ // File path relative to the root extracted directory or a sub directory if subpath is set.
+ private getInnerPath(path: string): string {
+ return path.slice(this.subPath.length);
}
private shouldBeIncluded(entry: Entry): boolean {
+ const strippedPath = this.stripTopDirectory(entry.path);
+
if (this.subPath) {
- if (!entry.path.startsWith(this.subPath)) {
+ if (!strippedPath.startsWith(this.subPath)) {
return false;
}
}
if (this.filter) {
- return this.filter(this.getPath(entry));
+ return this.filter(this.getInnerPath(entry.path));
}
return true;
}
@@ -91,7 +104,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
if (this.shouldBeIncluded(entry)) {
files.push({
- path: this.getPath(entry),
+ path: this.getInnerPath(this.stripTopDirectory(entry.path)),
content: () => entry.buffer(),
});
} else {
@@ -115,7 +128,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
.pipe(unzipper.Parse())
.on('entry', (entry: Entry) => {
if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
- archive.append(entry, { name: this.getPath(entry) });
+ archive.append(entry, { name: this.getInnerPath(entry.path) });
} else {
entry.autodrain();
}
@@ -131,7 +144,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
const dir =
options?.targetDir ??
- (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+ (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-')));
await this.stream
.pipe(unzipper.Parse())
@@ -139,12 +152,14 @@ export class ZipArchiveResponse implements ReadTreeResponse {
// Ignore directory entries since we handle that with the file entries
// as a zip can have files with directories without directory entries
if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
- const entryPath = this.getPath(entry);
- const dirname = path.dirname(entryPath);
+ const entryPath = this.getInnerPath(
+ this.stripTopDirectory(entry.path),
+ );
+ const dirname = platformPath.dirname(entryPath);
if (dirname) {
- await fs.mkdirp(path.join(dir, dirname));
+ await fs.mkdirp(platformPath.join(dir, dirname));
}
- entry.pipe(fs.createWriteStream(path.join(dir, entryPath)));
+ entry.pipe(fs.createWriteStream(platformPath.join(dir, entryPath)));
} else {
entry.autodrain();
}
diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts
index e98f760d8f..f9dfbe9aff 100644
--- a/packages/backend-common/src/reading/types.ts
+++ b/packages/backend-common/src/reading/types.ts
@@ -81,6 +81,9 @@ export type ReadTreeResponseDirOptions = {
};
export type ReadTreeResponse = {
+ /**
+ * files() returns an array of all the files inside the tree and corresponding functions to read their content.
+ */
files(): Promise;
archive(): Promise;
diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts
index b0a1df6541..5b24d23b99 100644
--- a/packages/backend-common/src/scm/git.ts
+++ b/packages/backend-common/src/scm/git.ts
@@ -86,13 +86,22 @@ export class Git {
return git.commit({ fs, dir, message, author, committer });
}
- async clone({ url, dir }: { url: string; dir: string }): Promise {
+ async clone({
+ url,
+ dir,
+ ref,
+ }: {
+ url: string;
+ dir: string;
+ ref?: string;
+ }): Promise {
this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
return git.clone({
fs,
http,
url,
dir,
+ ref,
singleBranch: true,
depth: 1,
onProgress: this.onProgressHandler(),
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index 1d840d8761..f85c22784d 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,53 @@
# example-backend
+## 0.2.14
+
+### Patch Changes
+
+- Updated dependencies [c777df180]
+- Updated dependencies [2430ee7c2]
+- Updated dependencies [3149bfe63]
+- Updated dependencies [6e612ce25]
+- Updated dependencies [e44925723]
+- Updated dependencies [9d6ef14bc]
+- Updated dependencies [a26668913]
+- Updated dependencies [025e122c3]
+- Updated dependencies [e9aab60c7]
+- Updated dependencies [24e47ef1e]
+- Updated dependencies [7881f2117]
+- Updated dependencies [529d16d27]
+- Updated dependencies [cdea0baf1]
+- Updated dependencies [11cb5ef94]
+ - @backstage/plugin-techdocs-backend@0.5.5
+ - @backstage/backend-common@0.5.2
+ - @backstage/plugin-catalog-backend@0.6.0
+ - @backstage/catalog-model@0.7.1
+ - example-app@0.2.14
+ - @backstage/plugin-scaffolder-backend@0.6.0
+ - @backstage/plugin-app-backend@0.3.6
+
+## 0.2.13
+
+### Patch Changes
+
+- Updated dependencies [26a3a6cf0]
+- Updated dependencies [681111228]
+- Updated dependencies [664dd08c9]
+- Updated dependencies [9dd057662]
+- Updated dependencies [234e7d985]
+- Updated dependencies [d7b1d317f]
+- Updated dependencies [a91aa6bf2]
+- Updated dependencies [39b05b9ae]
+- Updated dependencies [4eaa06057]
+ - @backstage/backend-common@0.5.1
+ - @backstage/plugin-scaffolder-backend@0.5.2
+ - @backstage/plugin-kubernetes-backend@0.2.6
+ - @backstage/plugin-catalog-backend@0.5.5
+ - @backstage/plugin-kafka-backend@0.2.0
+ - @backstage/plugin-auth-backend@0.2.12
+ - example-app@0.2.13
+ - @backstage/plugin-app-backend@0.3.5
+
## 0.2.12
### Patch Changes
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 269089a400..544bb976fe 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.2.12",
+ "version": "0.2.14",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -27,24 +27,24 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.5.0",
- "@backstage/catalog-model": "^0.7.0",
+ "@backstage/backend-common": "^0.5.2",
+ "@backstage/catalog-model": "^0.7.1",
"@backstage/config": "^0.1.2",
- "@backstage/plugin-app-backend": "^0.3.4",
- "@backstage/plugin-auth-backend": "^0.2.11",
- "@backstage/plugin-catalog-backend": "^0.5.4",
+ "@backstage/plugin-app-backend": "^0.3.6",
+ "@backstage/plugin-auth-backend": "^0.2.12",
+ "@backstage/plugin-catalog-backend": "^0.6.0",
"@backstage/plugin-graphql-backend": "^0.1.5",
- "@backstage/plugin-kubernetes-backend": "^0.2.5",
- "@backstage/plugin-kafka-backend": "^0.1.1",
+ "@backstage/plugin-kubernetes-backend": "^0.2.6",
+ "@backstage/plugin-kafka-backend": "^0.2.0",
"@backstage/plugin-proxy-backend": "^0.2.4",
"@backstage/plugin-rollbar-backend": "^0.1.7",
- "@backstage/plugin-scaffolder-backend": "^0.5.0",
- "@backstage/plugin-techdocs-backend": "^0.5.4",
+ "@backstage/plugin-scaffolder-backend": "^0.6.0",
+ "@backstage/plugin-techdocs-backend": "^0.5.5",
"@gitbeaker/node": "^28.0.2",
"@octokit/rest": "^18.0.12",
"azure-devops-node-api": "^10.1.1",
"dockerode": "^3.2.1",
- "example-app": "^0.2.12",
+ "example-app": "^0.2.14",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.6",
@@ -54,7 +54,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.4.7",
+ "@backstage/cli": "^0.6.0",
"@types/dockerode": "^3.2.1",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5"
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index 0b68446463..00b05e37bf 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -29,12 +29,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/catalog-model": "^0.7.0",
+ "@backstage/catalog-model": "^0.7.1",
"@backstage/config": "^0.1.2",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
- "@backstage/cli": "^0.4.7",
+ "@backstage/cli": "^0.6.0",
"@types/jest": "^26.0.7",
"msw": "^0.21.2"
},
diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md
index 6a081c3336..8d581dcb0c 100644
--- a/packages/catalog-model/CHANGELOG.md
+++ b/packages/catalog-model/CHANGELOG.md
@@ -1,5 +1,14 @@
# @backstage/catalog-model
+## 0.7.1
+
+### Patch Changes
+
+- 6e612ce25: Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them.
+- 025e122c3: Replace `yup` with `ajv`, for validation of catalog entities.
+- 7881f2117: Introduce json schema variants of the `yup` validation schemas
+- 11cb5ef94: Implement matchEntityWithRef for client side filtering of entities by ref matching
+
## 0.7.0
### Minor Changes
diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml
index 05afc265c0..9a0d690b57 100644
--- a/packages/catalog-model/examples/acme/org.yaml
+++ b/packages/catalog-model/examples/acme/org.yaml
@@ -3,6 +3,11 @@ kind: Group
metadata:
name: acme-corp
description: The acme-corp organization
+ links:
+ - url: http://www.acme.com/
+ title: Website
+ - url: https://meta.wikimedia.org/wiki/
+ title: Intranet
spec:
type: organization
profile:
diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml
index a314b4e255..5b6878f7d3 100644
--- a/packages/catalog-model/examples/apis/petstore-api.yaml
+++ b/packages/catalog-model/examples/apis/petstore-api.yaml
@@ -6,6 +6,13 @@ metadata:
tags:
- store
- rest
+ links:
+ - url: https://github.com/swagger-api/swagger-petstore
+ title: GitHub Repo
+ icon: github
+ - url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml
+ title: API Spec
+ icon: code
spec:
type: openapi
lifecycle: experimental
diff --git a/packages/catalog-model/examples/apis/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml
index 725811d0cc..4e0dbb1396 100644
--- a/packages/catalog-model/examples/apis/streetlights-api.yaml
+++ b/packages/catalog-model/examples/apis/streetlights-api.yaml
@@ -5,6 +5,10 @@ metadata:
description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
tags:
- mqtt
+ links:
+ - url: https://github.com/asyncapi/asyncapi/blob/master/examples/1.2.0/streetlights.yml
+ title: Source Code
+ icon: code
spec:
type: asyncapi
lifecycle: production
diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml
index 0c1f7af5a4..f3d924fc53 100644
--- a/packages/catalog-model/examples/apis/swapi-graphql.yaml
+++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml
@@ -3,6 +3,10 @@ kind: API
metadata:
name: starwars-graphql
description: SWAPI GraphQL Schema
+ links:
+ - url: https://github.com/graphql/swapi-graphql
+ title: GitHub Repo
+ icon: github
spec:
type: graphql
lifecycle: production
diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
index 3fc516ece9..fbf28d7fcb 100644
--- a/packages/catalog-model/examples/components/artist-lookup-component.yaml
+++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml
@@ -6,6 +6,12 @@ metadata:
tags:
- java
- data
+ links:
+ - url: https://example.com/apm/artists-lookup
+ title: APM
+ icon: dashboard
+ - url: https://example.com/logs/artists-lookup
+ title: Logs
spec:
type: service
lifecycle: experimental
diff --git a/packages/catalog-model/examples/components/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml
index acbb2f82b0..48286f9b74 100644
--- a/packages/catalog-model/examples/components/petstore-component.yaml
+++ b/packages/catalog-model/examples/components/petstore-component.yaml
@@ -3,6 +3,10 @@ kind: Component
metadata:
name: petstore
description: Petstore
+ links:
+ - url: https://github.com/swagger-api/swagger-petstore
+ title: GitHub Repo
+ icon: github
spec:
type: service
lifecycle: experimental
diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml
index 7bcc4329dd..2a74425791 100644
--- a/packages/catalog-model/examples/domains/artists-domain.yaml
+++ b/packages/catalog-model/examples/domains/artists-domain.yaml
@@ -3,5 +3,11 @@ kind: Domain
metadata:
name: artists
description: Everything related to artists
+ links:
+ - url: http://example.com/domain/artists/
+ title: Domain Readme
+ - url: http://example.com/domains/artists/dashboard
+ title: Domain Metrics Dashboard
+ icon: dashboard
spec:
owner: team-a
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index e03ce8c465..af74b78a08 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
- "version": "0.7.0",
+ "version": "0.7.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -32,13 +32,14 @@
"@backstage/config": "^0.1.2",
"@types/json-schema": "^7.0.5",
"@types/yup": "^0.29.8",
+ "ajv": "^7.0.3",
"json-schema": "^0.2.5",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
"yup": "^0.29.3"
},
"devDependencies": {
- "@backstage/cli": "^0.4.7",
+ "@backstage/cli": "^0.6.0",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts
index cd869f798c..b67180241e 100644
--- a/packages/catalog-model/src/EntityPolicies.test.ts
+++ b/packages/catalog-model/src/EntityPolicies.test.ts
@@ -1,4 +1,3 @@
-import { Entity } from './entity';
/*
* Copyright 2020 Spotify AB
*
@@ -15,8 +14,8 @@ import { Entity } from './entity';
* limitations under the License.
*/
+import { Entity, EntityPolicy } from './entity';
import { EntityPolicies } from './EntityPolicies';
-import { EntityPolicy } from './types';
describe('EntityPolicies', () => {
const p1: jest.Mocked = { enforce: jest.fn() };
diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts
index 1f304f8f27..2d576b1670 100644
--- a/packages/catalog-model/src/EntityPolicies.ts
+++ b/packages/catalog-model/src/EntityPolicies.ts
@@ -14,8 +14,7 @@
* limitations under the License.
*/
-import { Entity } from './entity';
-import { EntityPolicy } from './types';
+import { Entity, EntityPolicy } from './entity';
// Helper that requires that all of a set of policies can be successfully
// applied
diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts
index ac43137546..7abe1f9c0c 100644
--- a/packages/catalog-model/src/entity/Entity.ts
+++ b/packages/catalog-model/src/entity/Entity.ts
@@ -125,6 +125,11 @@ export type EntityMeta = JsonObject & {
* various ways.
*/
tags?: string[];
+
+ /**
+ * A list of external hyperlinks related to the entity.
+ */
+ links?: EntityLink[];
};
/**
@@ -161,3 +166,23 @@ export type EntityRelationSpec = {
*/
target: EntityName;
};
+
+/**
+ * A link to external information that is related to the entity.
+ */
+export type EntityLink = {
+ /**
+ * The url to the external site, document, etc.
+ */
+ url: string;
+
+ /**
+ * An optional descriptive title for the link.
+ */
+ title?: string;
+
+ /**
+ * An optional semantic key that represents a visual icon.
+ */
+ icon?: string;
+};
diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts
index d450119f0a..e80e14f7a4 100644
--- a/packages/catalog-model/src/entity/index.ts
+++ b/packages/catalog-model/src/entity/index.ts
@@ -20,12 +20,14 @@ export {
} from './constants';
export type {
Entity,
+ EntityLink,
EntityMeta,
EntityRelation,
EntityRelationSpec,
} from './Entity';
export * from './policies';
export {
+ compareEntityToRef,
getEntityName,
parseEntityName,
parseEntityRef,
diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
index 3119164454..4f5bbe04f4 100644
--- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
@@ -15,7 +15,7 @@
*/
import lodash from 'lodash';
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
import { Entity } from '../Entity';
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
index edf04a64cc..33ee045c1e 100644
--- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
@@ -38,6 +38,10 @@ describe('FieldFormatEntityPolicy', () => {
tags:
- java
- data-service
+ links:
+ - url: https://example.org
+ title: Website
+ icon: website
spec:
custom: stuff
`);
@@ -110,4 +114,110 @@ describe('FieldFormatEntityPolicy', () => {
data.metadata.tags.push('Hello World');
await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i);
});
+
+ it('accepts missing links', async () => {
+ delete data.metadata.links;
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('accepts empty links array', async () => {
+ data.metadata.links = [];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('accepts multiple links', async () => {
+ data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('rejects missing link url value', async () => {
+ data.metadata.links = [{}];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.url/i);
+ });
+
+ it('rejects a single bad missing link url value', async () => {
+ data.metadata.links = [{ url: 'http://good' }, { url: '' }];
+ await expect(policy.enforce(data)).rejects.toThrow(
+ /links.1.url.*valid url/i,
+ );
+ });
+
+ it('rejects empty link url value', async () => {
+ data.metadata.links = [{ url: '' }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.url.*/i);
+ });
+
+ it('rejects bad link url value', async () => {
+ data.metadata.links = [{ url: 'invalid' }];
+ await expect(policy.enforce(data)).rejects.toThrow(
+ /links.0.url.*"invalid"/i,
+ );
+ });
+
+ it('accepts missing link title', async () => {
+ data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('rejects empty link title', async () => {
+ data.metadata.links = [{ url: 'http://foo', title: '' }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*""/i);
+ });
+
+ it('rejects bad link title', async () => {
+ data.metadata.links = [{ url: 'http://foo', title: 123 }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*"123"/i);
+ });
+
+ it.each([[123], [{}], [[]]])(
+ 'rejects bad link title %s',
+ async (title: unknown) => {
+ data.metadata.links = [{ url: 'http://foo', title }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*/i);
+ },
+ );
+
+ it('rejects a single bad link title', async () => {
+ data.metadata.links = [
+ { url: 'http://foo', title: 'good' },
+ { url: 'http://foo', title: '' },
+ ];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.1.title.*""/i);
+ });
+
+ it('accepts missing link icon', async () => {
+ data.metadata.links = [{ url: 'http://foo', title: 'foo' }];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('rejects empty link icon', async () => {
+ data.metadata.links = [{ url: 'http://foo', icon: '' }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*""/i);
+ });
+
+ it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])(
+ 'accepts valid link icon',
+ async icon => {
+ data.metadata.links = [{ url: 'http://foo', icon }];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ },
+ );
+
+ it.each([[123], [{}], [[]], ['abc xyz']])(
+ 'rejects bad link icon value %s',
+ async (icon: unknown) => {
+ data.metadata.links = [{ url: 'http://foo', icon }];
+ await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*/i);
+ },
+ );
+
+ it('rejects a single bad link icon value', async () => {
+ data.metadata.links = [
+ { url: 'http://foo', icon: 'good' },
+ { url: 'http://foo', icon: 'not good' },
+ ];
+ await expect(policy.enforce(data)).rejects.toThrow(
+ /links.1.icon.*"not good"/i,
+ );
+ });
});
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
index 0aabbd9e48..1ed13c972c 100644
--- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
import {
CommonValidatorFunctions,
KubernetesValidatorFunctions,
@@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
expectation =
'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total';
break;
+ case 'isValidUrl':
+ expectation = 'a string that is a valid url';
+ break;
+ case 'isValidString':
+ expectation = 'a non empty string';
+ break;
default:
expectation = undefined;
break;
@@ -134,6 +140,23 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
require(`tags.${i}`, tags[i], this.validators.isValidTag);
}
+ const links = entity.metadata.links ?? [];
+
+ for (let i = 0; i < links.length; ++i) {
+ require(`links.${i}.url`, links[i]
+ ?.url, CommonValidatorFunctions.isValidUrl);
+ optional(
+ `links.${i}.title`,
+ links[i]?.title,
+ CommonValidatorFunctions.isValidString,
+ );
+ optional(
+ `links.${i}.icon`,
+ links[i]?.icon,
+ KubernetesValidatorFunctions.isValidObjectName,
+ );
+ }
+
return entity;
}
}
diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
index 9d1851bc02..7d401542ba 100644
--- a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
import { Entity } from '../Entity';
const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec'];
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
index d63ba49dbe..c84bdbdd38 100644
--- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
@@ -39,6 +39,10 @@ describe('SchemaValidEntityPolicy', () => {
tags:
- java
- data
+ links:
+ - url: https://example.com
+ title: Website
+ icon: website
spec:
custom: stuff
`);
@@ -198,6 +202,24 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/tags/);
});
+ it('accepts missing links', async () => {
+ delete data.metadata.links;
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it('accepts empty links array', async () => {
+ data.metadata.links = [];
+ await expect(policy.enforce(data)).resolves.toBe(data);
+ });
+
+ it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])(
+ 'rejects bad links type %s',
+ async (val: unknown) => {
+ data.metadata.links = val;
+ await expect(policy.enforce(data)).rejects.toThrow(/links/);
+ },
+ );
+
//
// spec
//
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
index 4ff73e9a49..7e0a8df268 100644
--- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
@@ -14,30 +14,12 @@
* limitations under the License.
*/
-import * as yup from 'yup';
-import { EntityPolicy } from '../../types';
+import Ajv, { ValidateFunction } from 'ajv';
+import entitySchema from '../../schema/Entity.schema.json';
+import entityMetaSchema from '../../schema/EntityMeta.schema.json';
+import commonSchema from '../../schema/shared/common.schema.json';
import { Entity } from '../Entity';
-
-const DEFAULT_ENTITY_SCHEMA = yup
- .object({
- apiVersion: yup.string().required(),
- kind: yup.string().required(),
- metadata: yup
- .object({
- uid: yup.string().notRequired().min(1),
- etag: yup.string().notRequired().min(1),
- generation: yup.number().notRequired().integer().min(1),
- name: yup.string().required(),
- namespace: yup.string().notRequired(),
- description: yup.string().notRequired(),
- labels: yup.object>().notRequired(),
- annotations: yup.object>().notRequired(),
- tags: yup.array().notRequired(),
- })
- .required(),
- spec: yup.object({}).notRequired(),
- })
- .required();
+import { EntityPolicy } from './types';
/**
* Ensures that the entity spec is valid according to a schema.
@@ -47,17 +29,28 @@ const DEFAULT_ENTITY_SCHEMA = yup
* typescript type.
*/
export class SchemaValidEntityPolicy implements EntityPolicy {
- private readonly schema: yup.Schema;
-
- constructor(schema: yup.Schema = DEFAULT_ENTITY_SCHEMA) {
- this.schema = schema;
- }
+ private validate: ValidateFunction | undefined;
async enforce(entity: Entity): Promise {
- try {
- return await this.schema.validate(entity, { strict: true });
- } catch (e) {
- throw new Error(`Malformed envelope, ${e}`);
+ if (!this.validate) {
+ const ajv = new Ajv({ allowUnionTypes: true });
+ this.validate = ajv
+ .addSchema([commonSchema, entityMetaSchema], undefined, undefined, true)
+ .compile(entitySchema);
}
+
+ const result = this.validate(entity);
+ if (result === true) {
+ return entity;
+ }
+
+ const [error] = this.validate.errors || [];
+ if (!error) {
+ throw new Error(`Malformed envelope, Unknown error`);
+ }
+
+ throw new Error(
+ `Malformed envelope, ${error.dataPath || ''} ${error.message}`,
+ );
}
}
diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts
index c381d29a23..5d75ef4d84 100644
--- a/packages/catalog-model/src/entity/policies/index.ts
+++ b/packages/catalog-model/src/entity/policies/index.ts
@@ -18,3 +18,4 @@ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
+export type { EntityPolicy } from './types';
diff --git a/packages/catalog-model/src/entity/policies/types.ts b/packages/catalog-model/src/entity/policies/types.ts
new file mode 100644
index 0000000000..415c98bbd2
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/types.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Entity } from '../Entity';
+
+/**
+ * A policy for validation or mutation to be applied to entities as they are
+ * entering the system.
+ */
+export type EntityPolicy = {
+ /**
+ * Applies validation or mutation on an entity.
+ *
+ * @param entity The entity, as validated/mutated so far in the policy tree
+ * @returns The incoming entity, or a mutated version of the same, or
+ * undefined if this processor could not handle the entity
+ * @throws An error if the entity should be rejected
+ */
+ enforce(entity: Entity): Promise;
+};
diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts
index bd7cc9477d..5ca511ee03 100644
--- a/packages/catalog-model/src/entity/ref.test.ts
+++ b/packages/catalog-model/src/entity/ref.test.ts
@@ -16,7 +16,12 @@
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
-import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
+import {
+ compareEntityToRef,
+ parseEntityName,
+ parseEntityRef,
+ serializeEntityRef,
+} from './ref';
describe('ref', () => {
describe('parseEntityName', () => {
@@ -381,4 +386,320 @@ describe('ref', () => {
).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' });
});
});
+
+ describe('compareEntityToRef', () => {
+ const entityWithNamespace: Entity = {
+ apiVersion: 'a',
+ kind: 'K',
+ metadata: {
+ name: 'n',
+ namespace: 'ns',
+ },
+ };
+ const entityWithoutNamespace: Entity = {
+ apiVersion: 'a',
+ kind: 'K',
+ metadata: {
+ name: 'n',
+ },
+ };
+
+ it('handles matching string refs', () => {
+ expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true);
+ expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'K:n', {
+ defaultNamespace: 'ns',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'K:n', {
+ defaultNamespace: 'Ns',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'n', {
+ defaultKind: 'K',
+ defaultNamespace: 'ns',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'N', {
+ defaultKind: 'k',
+ defaultNamespace: 'nS',
+ }),
+ ).toBe(true);
+
+ expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
+ true,
+ );
+ expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe(
+ true,
+ );
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'K:n', {
+ defaultNamespace: 'default',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'K:n', {
+ defaultNamespace: 'deFault',
+ }),
+ ).toBe(true);
+ expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
+ true,
+ );
+ expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'default/n', {
+ defaultKind: 'K',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'n', {
+ defaultKind: 'K',
+ defaultNamespace: 'default',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'n', {
+ defaultKind: 'K',
+ }),
+ ).toBe(true);
+ });
+
+ it('handles mismatching string refs', () => {
+ expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'ns/n', {
+ defaultKind: 'X',
+ }),
+ ).toBe(false);
+
+ expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'K:n', {
+ defaultNamespace: 'xx',
+ }),
+ ).toBe(false);
+
+ expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, 'x', {
+ defaultKind: 'K',
+ defaultNamespace: 'ns',
+ }),
+ ).toBe(false);
+ });
+
+ it('handles matching compound refs', () => {
+ expect(
+ compareEntityToRef(entityWithNamespace, {
+ kind: 'K',
+ namespace: 'ns',
+ name: 'n',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, {
+ kind: 'k',
+ namespace: 'Ns',
+ name: 'N',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithNamespace,
+ { kind: 'K', name: 'n' },
+ {
+ defaultNamespace: 'ns',
+ },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithNamespace,
+ { namespace: 'ns', name: 'n' },
+ { defaultKind: 'K' },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'n', {
+ defaultKind: 'K',
+ defaultNamespace: 'ns',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithNamespace, 'N', {
+ defaultKind: 'k',
+ defaultNamespace: 'nS',
+ }),
+ ).toBe(true);
+
+ expect(
+ compareEntityToRef(entityWithoutNamespace, {
+ kind: 'K',
+ namespace: 'default',
+ name: 'n',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, {
+ kind: 'k',
+ namespace: 'deFault',
+ name: 'N',
+ }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ { kind: 'K', name: 'n' },
+ {
+ defaultNamespace: 'default',
+ },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ { namespace: 'default', name: 'n' },
+ {
+ defaultKind: 'K',
+ },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ { name: 'n' },
+ {
+ defaultKind: 'K',
+ defaultNamespace: 'default',
+ },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ { name: 'N' },
+ {
+ defaultKind: 'k',
+ defaultNamespace: 'defAult',
+ },
+ ),
+ ).toBe(true);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ { name: 'n' },
+ {
+ defaultKind: 'K',
+ },
+ ),
+ ).toBe(true);
+ });
+
+ it('handles mismatching compound refs', () => {
+ expect(
+ compareEntityToRef(entityWithNamespace, {
+ kind: 'X',
+ namespace: 'ns',
+ name: 'n',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(
+ entityWithNamespace,
+ {
+ namespace: 'ns',
+ name: 'n',
+ },
+ { defaultKind: 'X' },
+ ),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, {
+ kind: 'X',
+ namespace: 'default',
+ name: 'n',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ {
+ namespace: 'default',
+ name: 'n',
+ },
+ { defaultKind: 'X' },
+ ),
+ ).toBe(false);
+
+ expect(
+ compareEntityToRef(entityWithNamespace, {
+ kind: 'K',
+ namespace: 'xx',
+ name: 'n',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(
+ entityWithNamespace,
+ {
+ kind: 'K',
+ name: 'n',
+ },
+ { defaultNamespace: 'xx' },
+ ),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, {
+ kind: 'K',
+ namespace: 'xx',
+ name: 'n',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ {
+ kind: 'K',
+ name: 'n',
+ },
+ { defaultNamespace: 'xx' },
+ ),
+ ).toBe(false);
+
+ expect(
+ compareEntityToRef(entityWithNamespace, {
+ kind: 'K',
+ namespace: 'ns',
+ name: 'x',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(entityWithoutNamespace, {
+ kind: 'K',
+ namespace: 'default',
+ name: 'x',
+ }),
+ ).toBe(false);
+ expect(
+ compareEntityToRef(
+ entityWithoutNamespace,
+ {
+ kind: 'K',
+ name: 'x',
+ },
+ { defaultNamespace: 'default' },
+ ),
+ ).toBe(false);
+ });
+ });
});
diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts
index 9f6f175d8d..bf34962d01 100644
--- a/packages/catalog-model/src/entity/ref.ts
+++ b/packages/catalog-model/src/entity/ref.ts
@@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types';
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
+function parseRefString(
+ ref: string,
+): {
+ kind?: string;
+ namespace?: string;
+ name: string;
+} {
+ const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
+ if (!match) {
+ throw new TypeError(
+ `Entity reference "${ref}" was not on the form [:][/]`,
+ );
+ }
+
+ return {
+ kind: match[1]?.slice(0, -1),
+ namespace: match[2]?.slice(0, -1),
+ name: match[3],
+ };
+}
+
/**
* Extracts the kind, namespace and name that form the name triplet of the
* given entity.
@@ -121,17 +142,11 @@ export function parseEntityRef(
}
if (typeof ref === 'string') {
- const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
- if (!match) {
- throw new Error(
- `Entity reference "${ref}" was not on the form [:][/]`,
- );
- }
-
+ const parsed = parseRefString(ref);
return {
- kind: match[1]?.slice(0, -1) ?? context.defaultKind,
- namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace,
- name: match[3],
+ kind: parsed.kind ?? context.defaultKind,
+ namespace: parsed.namespace ?? context.defaultNamespace,
+ name: parsed.name,
};
}
@@ -196,3 +211,53 @@ export function serializeEntityRef(
return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`;
}
+
+/**
+ * Compares an entity to either a string reference or a compound reference.
+ *
+ * The comparison is case insensitive, and all of kind, namespace, and name
+ * must match (after applying the optional context to the ref).
+ *
+ * @param entity The entity to match
+ * @param ref A string or compound entity ref
+ * @param context An optional context of default kind and namespace, that apply
+ * to the ref if given
+ * @returns True if matching, false otherwise
+ */
+export function compareEntityToRef(
+ entity: Entity,
+ ref: EntityRef | EntityName,
+ context?: EntityRefContext,
+): boolean {
+ const entityKind = entity.kind;
+ const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE;
+ const entityName = entity.metadata.name;
+
+ let refKind: string | undefined;
+ let refNamespace: string | undefined;
+ let refName: string;
+ if (typeof ref === 'string') {
+ const parsed = parseRefString(ref);
+ refKind = parsed.kind || context?.defaultKind;
+ refNamespace =
+ parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
+ refName = parsed.name;
+ } else {
+ refKind = ref.kind || context?.defaultKind;
+ refNamespace =
+ ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
+ refName = ref.name;
+ }
+
+ if (!refKind || !refNamespace) {
+ throw new Error(
+ `Entity reference or context did not contain kind and namespace`,
+ );
+ }
+
+ return (
+ entityKind.toLowerCase() === refKind.toLowerCase() &&
+ entityNamespace.toLowerCase() === refNamespace.toLowerCase() &&
+ entityName.toLowerCase() === refName.toLowerCase()
+ );
+}
diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts
index f93a4001a5..976b5f6148 100644
--- a/packages/catalog-model/src/index.ts
+++ b/packages/catalog-model/src/index.ts
@@ -18,5 +18,5 @@ export * from './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export * from './location';
-export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types';
+export type { EntityName, EntityRef, JSONSchema } from './types';
export * from './validation';
diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
index 2c634ff091..5432cafdeb 100644
--- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
@@ -14,27 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/API.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'API' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().required().min(1),
- lifecycle: yup.string().required().min(1),
- owner: yup.string().required().min(1),
- definition: yup.string().required().min(1),
- system: yup.string().notRequired().min(1),
- })
- .required(),
-});
-
export interface ApiEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -47,8 +36,9 @@ export interface ApiEntityV1alpha1 extends Entity {
};
}
-export const apiEntityV1alpha1Validator = schemaValidator(
+export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index c55c48055a..3006a4288c 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -14,29 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Component.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Component' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().required().min(1),
- lifecycle: yup.string().required().min(1),
- owner: yup.string().required().min(1),
- subcomponentOf: yup.string().notRequired().min(1),
- providesApis: yup.array(yup.string().required()).notRequired(),
- consumesApis: yup.array(yup.string().required()).notRequired(),
- system: yup.string().notRequired().min(1),
- })
- .required(),
-});
-
export interface ComponentEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -51,8 +38,9 @@ export interface ComponentEntityV1alpha1 extends Entity {
};
}
-export const componentEntityV1alpha1Validator = schemaValidator(
+export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
index 60b11aa124..7aab35e367 100644
--- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
@@ -14,23 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Domain.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Domain' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- owner: yup.string().required().min(1),
- })
- .required(),
-});
-
export interface DomainEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -39,8 +32,9 @@ export interface DomainEntityV1alpha1 extends Entity {
};
}
-export const domainEntityV1alpha1Validator = schemaValidator(
+export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index 6f2664af77..d039fdaba8 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -14,40 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Group.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Group' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().required().min(1),
- profile: yup
- .object({
- displayName: yup.string().min(1).notRequired(),
- email: yup.string().min(1).notRequired(),
- picture: yup.string().min(1).notRequired(),
- })
- .notRequired(),
- parent: yup.string().notRequired().min(1),
- // Use these manual tests because yup .required() requires at least
- // one element and there is no simple workaround -_-
- // the cast is there to convince typescript that the array itself is
- // required without using .required()
- children: yup.array(yup.string().required()).test({
- name: 'isDefined',
- message: 'children must be defined',
- test: v => Boolean(v),
- }) as yup.ArraySchema,
- })
- .required(),
-});
-
export interface GroupEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -63,8 +39,9 @@ export interface GroupEntityV1alpha1 extends Entity {
};
}
-export const groupEntityV1alpha1Validator = schemaValidator(
+export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
index 9cd767de94..fb452b6ac7 100644
--- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
@@ -14,25 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Location.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Location' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().notRequired().min(1),
- target: yup.string().notRequired().min(1),
- targets: yup.array(yup.string().required()).notRequired(),
- })
- .required(),
-});
-
export interface LocationEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -43,8 +34,9 @@ export interface LocationEntityV1alpha1 extends Entity {
};
}
-export const locationEntityV1alpha1Validator = schemaValidator(
+export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
index 12df7f6664..520a39f02c 100644
--- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
@@ -14,25 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Resource.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Resource' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().required().min(1),
- owner: yup.string().required().min(1),
- system: yup.string().notRequired().min(1),
- })
- .required(),
-});
-
export interface ResourceEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -43,8 +34,9 @@ export interface ResourceEntityV1alpha1 extends Entity {
};
}
-export const resourceEntityV1alpha1Validator = schemaValidator(
+export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
index 764514efdd..1ee19466f0 100644
--- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
@@ -14,24 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/System.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'System' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- owner: yup.string().required().min(1),
- domain: yup.string().notRequired().min(1),
- })
- .required(),
-});
-
export interface SystemEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -41,8 +33,9 @@ export interface SystemEntityV1alpha1 extends Entity {
};
}
-export const systemEntityV1alpha1Validator = schemaValidator(
+export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
index b16fcdc7e4..72479c7efa 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
@@ -14,27 +14,17 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
+import schema from '../schema/kinds/Template.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
import type { JSONSchema } from '../types';
-import { schemaValidator } from './util';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Template' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- type: yup.string().required().min(1),
- path: yup.string(),
- schema: yup.object().required(),
- templater: yup.string().required(),
- })
- .required(),
-});
-
export interface TemplateEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -46,8 +36,9 @@ export interface TemplateEntityV1alpha1 extends Entity {
};
}
-export const templateEntityV1alpha1Validator = schemaValidator(
+export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
index 16a5a86e05..a8700a496e 100644
--- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
@@ -14,38 +14,16 @@
* limitations under the License.
*/
-import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/User.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'User' as const;
-const schema = yup.object>({
- apiVersion: yup.string().required().oneOf(API_VERSION),
- kind: yup.string().required().equals([KIND]),
- spec: yup
- .object({
- profile: yup
- .object({
- displayName: yup.string().min(1).notRequired(),
- email: yup.string().min(1).notRequired(),
- picture: yup.string().min(1).notRequired(),
- })
- .notRequired(),
- // Use this manual test because yup .required() requires at least one
- // element and there is no simple workaround -_-
- // the cast is there to convince typescript that the array itself is
- // required without using .required()
- memberOf: yup.array(yup.string().required()).test({
- name: 'isDefined',
- message: 'memberOf must be defined',
- test: v => Boolean(v),
- }) as yup.ArraySchema,
- })
- .required(),
-});
-
export interface UserEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
@@ -59,8 +37,9 @@ export interface UserEntityV1alpha1 extends Entity {
};
}
-export const userEntityV1alpha1Validator = schemaValidator(
+export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
KIND,
API_VERSION,
schema,
+ [commonSchema, entityMetaSchema, entitySchema],
);
diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts
index f1b02cfba1..4002f2806c 100644
--- a/packages/catalog-model/src/kinds/util.ts
+++ b/packages/catalog-model/src/kinds/util.ts
@@ -14,9 +14,13 @@
* limitations under the License.
*/
+import Ajv, { AnySchema } from 'ajv';
import * as yup from 'yup';
import { KindValidator } from './types';
+/**
+ * @deprecated We no longer use yup for the catalog model. This utility method will be removed.
+ */
export function schemaValidator(
kind: string,
apiVersion: readonly string[],
@@ -24,10 +28,7 @@ export function schemaValidator(
): KindValidator {
return {
async check(envelope) {
- if (
- kind !== envelope.kind ||
- !apiVersion.includes(envelope.apiVersion as any)
- ) {
+ if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
return false;
}
await schema.validate(envelope, { strict: true });
@@ -35,3 +36,38 @@ export function schemaValidator(
},
};
}
+
+export function ajvCompiledJsonSchemaValidator(
+ kind: string,
+ apiVersion: readonly string[],
+ schema: AnySchema,
+ extraSchemas?: AnySchema[],
+): KindValidator {
+ const ajv = new Ajv({ allowUnionTypes: true });
+ if (extraSchemas) {
+ ajv.addSchema(extraSchemas, undefined, undefined, true);
+ }
+ const validate = ajv.compile(schema);
+
+ return {
+ async check(envelope) {
+ if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
+ return false;
+ }
+
+ const result = validate(envelope);
+ if (result === true) {
+ return true;
+ }
+
+ const [error] = validate.errors || [];
+ if (!error) {
+ throw new TypeError(`Malformed ${kind}, Unknown error`);
+ }
+
+ throw new TypeError(
+ `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`,
+ );
+ },
+ };
+}
diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json
new file mode 100644
index 0000000000..803e025796
--- /dev/null
+++ b/packages/catalog-model/src/schema/Entity.schema.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "Entity",
+ "description": "The format envelope that's common to all versions/kinds of entity.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Component",
+ "metadata": {
+ "name": "LoremService",
+ "description": "Creates Lorems like a pro.",
+ "labels": {
+ "product_name": "Random value Generator"
+ },
+ "annotations": {
+ "docs": "https://github.com/..../tree/develop/doc"
+ }
+ },
+ "spec": {
+ "type": "service",
+ "lifecycle": "production",
+ "owner": "tools"
+ }
+ }
+ ],
+ "type": "object",
+ "required": ["apiVersion", "kind", "metadata"],
+ "additionalProperties": false,
+ "properties": {
+ "apiVersion": {
+ "type": "string",
+ "description": "The version of specification format for this particular entity that this is written against.",
+ "minLength": 1,
+ "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"]
+ },
+ "kind": {
+ "type": "string",
+ "description": "The high level entity type being described.",
+ "minLength": 1,
+ "examples": [
+ "API",
+ "Component",
+ "Domain",
+ "Group",
+ "Location",
+ "Resource",
+ "System",
+ "Template",
+ "User"
+ ]
+ },
+ "metadata": {
+ "$ref": "EntityMeta"
+ },
+ "spec": {
+ "type": "object",
+ "description": "The specification data describing the entity itself."
+ },
+ "relations": {
+ "type": "array",
+ "description": "The relations that this entity has with other entities.",
+ "items": {
+ "$ref": "common#relation"
+ }
+ }
+ }
+}
diff --git a/packages/catalog-model/src/schema/EntityMeta.schema.json b/packages/catalog-model/src/schema/EntityMeta.schema.json
new file mode 100644
index 0000000000..ff0f9c84a8
--- /dev/null
+++ b/packages/catalog-model/src/schema/EntityMeta.schema.json
@@ -0,0 +1,117 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "EntityMeta",
+ "description": "Metadata fields common to all versions/kinds of entity.",
+ "examples": [
+ {
+ "uid": "e01199ab-08cc-44c2-8e19-5c29ded82521",
+ "etag": "lsndfkjsndfkjnsdfkjnsd==",
+ "generation": 13,
+ "name": "my-component-yay",
+ "namespace": "the-namespace",
+ "labels": {
+ "backstage.io/custom": "ValueStuff"
+ },
+ "annotations": {
+ "example.com/bindings": "are-secret"
+ },
+ "tags": ["java", "data"]
+ }
+ ],
+ "type": "object",
+ "required": ["name"],
+ "additionalProperties": true,
+ "properties": {
+ "uid": {
+ "type": "string",
+ "description": "A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.",
+ "examples": ["e01199ab-08cc-44c2-8e19-5c29ded82521"],
+ "minLength": 1
+ },
+ "etag": {
+ "type": "string",
+ "description": "An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.",
+ "examples": ["lsndfkjsndfkjnsdfkjnsd=="],
+ "minLength": 1
+ },
+ "generation": {
+ "type": "integer",
+ "description": "A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations.",
+ "examples": [1],
+ "minimum": 1
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.",
+ "examples": ["metadata-proxy"],
+ "minLength": 1
+ },
+ "namespace": {
+ "type": "string",
+ "description": "The namespace that the entity belongs to.",
+ "default": "default",
+ "examples": ["default", "admin"],
+ "minLength": 1
+ },
+ "description": {
+ "type": "string",
+ "description": "A short (typically relatively few words, on one line) description of the entity."
+ },
+ "labels": {
+ "type": "object",
+ "description": "Key/value pairs of identifying information attached to the entity.",
+ "additionalProperties": true,
+ "patternProperties": {
+ "^.+$": {
+ "type": "string"
+ }
+ }
+ },
+ "annotations": {
+ "type": "object",
+ "description": "Key/value pairs of non-identifying auxiliary information attached to the entity.",
+ "additionalProperties": true,
+ "patternProperties": {
+ "^.+$": {
+ "type": "string"
+ }
+ }
+ },
+ "tags": {
+ "type": "array",
+ "description": "A list of single-valued strings, to for example classify catalog entities in various ways.",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "links": {
+ "type": "array",
+ "description": "A list of external hyperlinks related to the entity. Links can provide additional contextual information that may be located outside of Backstage itself. For example, an admin dashboard or external CMS page.",
+ "items": {
+ "type": "object",
+ "required": ["url"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A url in a standard uri format.",
+ "examples": ["https://admin.example-org.com"],
+ "minLength": 1
+ },
+ "title": {
+ "type": "string",
+ "description": "A user friendly display name for the link.",
+ "examples": ["Admin Dashboard"],
+ "minLength": 1
+ },
+ "icon": {
+ "type": "string",
+ "description": "A key representing a visual icon to be displayed in the UI.",
+ "examples": ["dashboard"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json
new file mode 100644
index 0000000000..fae1225764
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json
@@ -0,0 +1,79 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "ApiV1alpha1",
+ "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "API",
+ "metadata": {
+ "name": "artist-api",
+ "description": "Retrieve artist details",
+ "labels": {
+ "product_name": "Random value Generator"
+ },
+ "annotations": {
+ "docs": "https://github.com/..../tree/develop/doc"
+ }
+ },
+ "spec": {
+ "type": "openapi",
+ "lifecycle": "production",
+ "owner": "artist-relations-team",
+ "system": "artist-engagement-portal",
+ "definition": "openapi: \"3.0.0\"\ninfo:..."
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["API"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "lifecycle", "owner", "definition"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of the API definition.",
+ "examples": ["openapi", "asyncapi", "graphql", "grpc"],
+ "minLength": 1
+ },
+ "lifecycle": {
+ "type": "string",
+ "description": "The lifecycle state of the API.",
+ "examples": ["experimental", "production", "deprecated"],
+ "minLength": 1
+ },
+ "owner": {
+ "type": "string",
+ "description": "An entity reference to the owner of the API.",
+ "examples": ["artist-relations-team", "user:john.johnson"],
+ "minLength": 1
+ },
+ "system": {
+ "type": "string",
+ "description": "An entity reference to the system that the API belongs to.",
+ "minLength": 1
+ },
+ "definition": {
+ "type": "string",
+ "description": "The definition of the API, based on the format defined by the type.",
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json
new file mode 100644
index 0000000000..822059a0ea
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json
@@ -0,0 +1,93 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "ComponentV1alpha1",
+ "description": "A Component describes a software component. It is typically intimately linked to the source code that constitutes the component, and should be what a developer may regard a \"unit of software\", usually with a distinct deployable or linkable artifact.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Component",
+ "metadata": {
+ "name": "LoremService",
+ "description": "Creates Lorems like a pro.",
+ "labels": {
+ "product_name": "Random value Generator"
+ },
+ "annotations": {
+ "docs": "https://github.com/..../tree/develop/doc"
+ }
+ },
+ "spec": {
+ "type": "service",
+ "lifecycle": "production",
+ "owner": "tools"
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Component"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "lifecycle", "owner"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of component.",
+ "examples": ["service", "website", "library"],
+ "minLength": 1
+ },
+ "lifecycle": {
+ "type": "string",
+ "description": "The lifecycle state of the component.",
+ "examples": ["experimental", "production", "deprecated"],
+ "minLength": 1
+ },
+ "owner": {
+ "type": "string",
+ "description": "An entity reference to the owner of the component.",
+ "examples": ["artist-relations-team", "user:john.johnson"],
+ "minLength": 1
+ },
+ "system": {
+ "type": "string",
+ "description": "An entity reference to the system that the component belongs to.",
+ "minLength": 1
+ },
+ "subcomponentOf": {
+ "type": "string",
+ "description": "An entity reference to another component of which the component is a part.",
+ "minLength": 1
+ },
+ "providesApis": {
+ "type": "array",
+ "description": "An array of entity references to the APIs that are provided by the component.",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "consumesApis": {
+ "type": "array",
+ "description": "An array of entity references to the APIs that are consumed by the component.",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json
new file mode 100644
index 0000000000..a1a09b1213
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "DomainV1alpha1",
+ "description": "A Domain groups a collection of systems that share terminology, domain models, business purpose, or documentation, i.e. form a bounded context.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Domain",
+ "metadata": {
+ "name": "artists",
+ "description": "Everything about artists"
+ },
+ "spec": {
+ "owner": "artist-relations-team"
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Domain"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["owner"],
+ "properties": {
+ "owner": {
+ "type": "string",
+ "description": "An entity reference to the owner of the component.",
+ "examples": ["artist-relations-team", "user:john.johnson"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json
new file mode 100644
index 0000000000..fc19d98301
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json
@@ -0,0 +1,95 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "GroupV1alpha1",
+ "description": "A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Group",
+ "metadata": {
+ "name": "infrastructure",
+ "description": "The infra business unit"
+ },
+ "spec": {
+ "type": "business-unit",
+ "profile": {
+ "displayName": "Infrastructure",
+ "email": "infrastructure@example.com",
+ "picture": "https://example.com/groups/bu-infrastructure.jpeg"
+ },
+ "parent": "ops",
+ "children": ["backstage", "other"]
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Group"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "children"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of group. There is currently no enforced set of values for this field, so it is left up to the adopting organization to choose a nomenclature that matches their org hierarchy.",
+ "examples": ["team", "business-unit", "product-area", "root"],
+ "minLength": 1
+ },
+ "profile": {
+ "type": "object",
+ "description": "Optional profile information about the group, mainly for display purposes. All fields of this structure are also optional. The email would be a group email of some form, that the group may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the group, and that a browser could fetch and render on a group page or similar.",
+ "properties": {
+ "displayName": {
+ "type": "string",
+ "description": "A simple display name to present to users.",
+ "examples": ["Infrastructure"],
+ "minLength": 1
+ },
+ "email": {
+ "type": "string",
+ "description": "An email where this entity can be reached.",
+ "examples": ["infrastructure@example.com"],
+ "minLength": 1
+ },
+ "picture": {
+ "type": "string",
+ "description": "The URL of an image that represents this entity.",
+ "examples": [
+ "https://example.com/groups/bu-infrastructure.jpeg"
+ ],
+ "minLength": 1
+ }
+ }
+ },
+ "parent": {
+ "type": "string",
+ "description": "The immediate parent group in the hierarchy, if any. Not all groups must have a parent; the catalog supports multi-root hierarchies. Groups may however not have more than one parent. This field is an entity reference.",
+ "examples": ["ops"],
+ "minLength": 1
+ },
+ "children": {
+ "type": "array",
+ "description": "The immediate child groups of this group in the hierarchy (whose parent field points to this group). The list must be present, but may be empty if there are no child groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.",
+ "items": {
+ "type": "string",
+ "examples": ["backstage", "other"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json
new file mode 100644
index 0000000000..d633d30229
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "LocationV1alpha1",
+ "description": "A location is a marker that references other places to look for catalog data.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Location",
+ "metadata": {
+ "name": "org-data"
+ },
+ "spec": {
+ "type": "url",
+ "targets": [
+ "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml",
+ "http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml"
+ ]
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Location"]
+ },
+ "spec": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The single location type, that's common to the targets specified in the spec. If it is left out, it is inherited from the location type that originally read the entity data.",
+ "examples": ["url"],
+ "minLength": 1
+ },
+ "target": {
+ "type": "string",
+ "description": "A single target as a string. Can be either an absolute path/URL (depending on the type), or a relative path such as ./details/catalog-info.yaml which is resolved relative to the location of this Location entity itself.",
+ "examples": ["./details/catalog-info.yaml"],
+ "minLength": 1
+ },
+ "targets": {
+ "type": "array",
+ "description": "A list of targets as strings. They can all be either absolute paths/URLs (depending on the type), or relative paths such as ./details/catalog-info.yaml which are resolved relative to the location of this Location entity itself.",
+ "items": {
+ "type": "string",
+ "examples": [
+ "./details/catalog-info.yaml",
+ "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml"
+ ],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json
new file mode 100644
index 0000000000..b426dd7f94
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json
@@ -0,0 +1,60 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "ResourceV1alpha1",
+ "description": "A resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Resource",
+ "metadata": {
+ "name": "artists-db",
+ "description": "Stores artist details"
+ },
+ "spec": {
+ "type": "database",
+ "owner": "artist-relations-team",
+ "system": "artist-engagement-portal"
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Resource"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "owner"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of resource.",
+ "examples": ["database", "s3-bucket", "cluster"],
+ "minLength": 1
+ },
+ "owner": {
+ "type": "string",
+ "description": "An entity reference to the owner of the resource.",
+ "examples": ["artist-relations-team", "user:john.johnson"],
+ "minLength": 1
+ },
+ "system": {
+ "type": "string",
+ "description": "An entity reference to the system that the resource belongs to.",
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json
new file mode 100644
index 0000000000..2cdbc37076
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "SystemV1alpha1",
+ "description": "A system is a collection of resources and components. The system may expose or consume one or several APIs. It is viewed as abstraction level that provides potential consumers insights into exposed features without needing a too detailed view into the details of all components. This also gives the owning team the possibility to decide about published artifacts and APIs.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "System",
+ "metadata": {
+ "name": "artist-engagement-portal",
+ "description": "Handy tools to keep artists in the loop"
+ },
+ "spec": {
+ "owner": "artist-relations-team",
+ "domain": "artists"
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["System"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["owner"],
+ "properties": {
+ "owner": {
+ "type": "string",
+ "description": "An entity reference to the owner of the component.",
+ "examples": ["artist-relations-team", "user:john.johnson"],
+ "minLength": 1
+ },
+ "domain": {
+ "type": "string",
+ "description": "An entity reference to the domain that the system belongs to.",
+ "examples": ["artists"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
new file mode 100644
index 0000000000..5bb83d116b
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
@@ -0,0 +1,94 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "TemplateV1alpha1",
+ "description": "A Template describes a skeleton for use with the Scaffolder. It is used for describing what templating library is supported, and also for documenting the variables that the template requires using JSON Forms Schema.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "Template",
+ "metadata": {
+ "name": "react-ssr-template",
+ "title": "React SSR Template",
+ "description": "Next.js application skeleton for creating isomorphic web applications.",
+ "tags": ["recommended", "react"]
+ },
+ "spec": {
+ "owner": "artist-relations-team",
+ "templater": "cookiecutter",
+ "type": "website",
+ "path": ".",
+ "schema": {
+ "required": ["component-id", "description"],
+ "properties": {
+ "component_id": {
+ "title": "Name",
+ "type": "string",
+ "description": "Unique name of the component"
+ },
+ "description": {
+ "title": "Description",
+ "type": "string",
+ "description": "Description of the component"
+ }
+ }
+ }
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["Template"]
+ },
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.",
+ "examples": ["React SSR Template"],
+ "minLength": 1
+ }
+ }
+ },
+ "spec": {
+ "type": "object",
+ "required": ["type", "templater", "schema"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.",
+ "examples": ["service", "website", "library"],
+ "minLength": 1
+ },
+ "templater": {
+ "type": "string",
+ "description": "The templating library that is supported by the template skeleton.",
+ "examples": ["cookiecutter"],
+ "minLength": 1
+ },
+ "path": {
+ "type": "string",
+ "description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.",
+ "examples": ["./cookiecutter/skeleton"],
+ "minLength": 1
+ },
+ "schema": {
+ "type": "object",
+ "description": "The JSONSchema describing the inputs for the template."
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json
new file mode 100644
index 0000000000..a71409d5ba
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json
@@ -0,0 +1,80 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "UserV1alpha1",
+ "description": "A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. These catalog user entries are connected to the way that authentication within the Backstage ecosystem works. See the auth section of the docs for a discussion of these concepts.",
+ "examples": [
+ {
+ "apiVersion": "backstage.io/v1alpha1",
+ "kind": "User",
+ "metadata": {
+ "name": "jdoe"
+ },
+ "spec": {
+ "profile": {
+ "displayName": "Jenny Doe",
+ "email": "jenny-doe@example.com",
+ "picture": "https://example.com/staff/jenny-with-party-hat.jpeg"
+ },
+ "memberOf": ["team-b", "employees"]
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "Entity"
+ },
+ {
+ "type": "object",
+ "required": ["spec"],
+ "properties": {
+ "apiVersion": {
+ "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ },
+ "kind": {
+ "enum": ["User"]
+ },
+ "spec": {
+ "type": "object",
+ "required": ["memberOf"],
+ "properties": {
+ "profile": {
+ "type": "object",
+ "description": "Optional profile information about the user, mainly for display purposes. All fields of this structure are also optional. The email would be a primary email of some form, that the user may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the user, and that a browser could fetch and render on a profile page or similar.",
+ "properties": {
+ "displayName": {
+ "type": "string",
+ "description": "A simple display name to present to users.",
+ "examples": ["Jenny Doe"],
+ "minLength": 1
+ },
+ "email": {
+ "type": "string",
+ "description": "An email where this user can be reached.",
+ "examples": ["jenny-doe@example.com"],
+ "minLength": 1
+ },
+ "picture": {
+ "type": "string",
+ "description": "The URL of an image that represents this user.",
+ "examples": [
+ "https://example.com/staff/jenny-with-party-hat.jpeg"
+ ],
+ "minLength": 1
+ }
+ }
+ },
+ "memberOf": {
+ "type": "array",
+ "description": "The list of groups that the user is a direct member of (i.e., no transitive memberships are listed here). The list must be present, but may be empty if the user is not member of any groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.",
+ "items": {
+ "type": "string",
+ "examples": ["team-b", "employees"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json
new file mode 100644
index 0000000000..84ae8a4147
--- /dev/null
+++ b/packages/catalog-model/src/schema/shared/common.schema.json
@@ -0,0 +1,50 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "$id": "common",
+ "type": "object",
+ "description": "Common definitions to import from other schemas",
+ "definitions": {
+ "reference": {
+ "$id": "#reference",
+ "type": "object",
+ "description": "A reference by name to another entity.",
+ "required": ["kind", "namespace", "name"],
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "type": "string",
+ "description": "The kind field of the entity."
+ },
+ "namespace": {
+ "type": "string",
+ "description": "The metadata.namespace field of the entity."
+ },
+ "name": {
+ "type": "string",
+ "description": "The metadata.name field of the entity."
+ }
+ }
+ },
+ "relation": {
+ "$id": "#relation",
+ "type": "object",
+ "description": "A directed relation from one entity to another.",
+ "required": ["type", "source", "target"],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^\\w+$",
+ "description": "The type of relation."
+ },
+ "source": {
+ "$ref": "#reference"
+ },
+ "target": {
+ "$ref": "#reference"
+ }
+ }
+ }
+ }
+}
diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts
index aa96e46102..edac03466d 100644
--- a/packages/catalog-model/src/types.ts
+++ b/packages/catalog-model/src/types.ts
@@ -16,23 +16,6 @@
import { JsonValue } from '@backstage/config';
import { JSONSchema7 } from 'json-schema';
-import type { Entity } from './entity/Entity';
-
-/**
- * A policy for validation or mutation to be applied to entities as they are
- * entering the system.
- */
-export type EntityPolicy = {
- /**
- * Applies validation or mutation on an entity.
- *
- * @param entity The entity, as validated/mutated so far in the policy tree
- * @returns The incoming entity, or a mutated version of the same, or
- * undefined if this processor could not handle the entity
- * @throws An error if the entity should be rejected
- */
- enforce(entity: Entity): Promise;
-};
export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue };
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
index 129fdd2b62..bef997d54a 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
@@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => {
])(`isValidDnsLabel %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result);
});
+
+ it.each([
+ [null, false],
+ [7, false],
+ ['', false],
+ ['abc', false],
+ [' abc', false],
+ ['', false],
+ [{}, false],
+ ['http://foo', true],
+ ['https://www.foo.com/', true],
+ ['https://foo.com:8080', true],
+ ['https://foo:8080/page', true],
+ ['https://foo:8080/sub/page', true],
+ ['https://foo:8080/sub/page?query', true],
+ ['https://foo:8080/sub/page/?query=value', true],
+ ['https://foo:8080/sub/page/?query=value&', true],
+ ['https://foo:8080/sub/page/?query=value&another=val', true],
+ ['https://foo.com/page#fragment', true],
+ ['ftp://ftp.some.domain.com/path', true],
+ ['xyz://custom-protocol:4444/path', true],
+ ])(`isValidUrl %p ? %p`, (value, result) => {
+ expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result);
+ });
+
+ it.each([
+ [null, false],
+ [true, false],
+ [7, false],
+ [{}, false],
+ ['', false],
+ [' ', false],
+ [' ', false],
+ ['abc', true],
+ [' abc ', true],
+ ['abc xyz', true],
+ ['abc xyz abc.', true],
+ ])(`isValidString %p ? %p`, (value, result) => {
+ expect(CommonValidatorFunctions.isValidString(value)).toBe(result);
+ });
});
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
index 90b4e1a611..87b6ad3838 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
@@ -92,4 +92,32 @@ export class CommonValidatorFunctions {
/^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value)
);
}
+
+ /**
+ * Checks that the value is a valid URL.
+ *
+ * @param value The value to check
+ */
+ static isValidUrl(value: unknown): boolean {
+ if (typeof value !== 'string') {
+ return false;
+ }
+
+ try {
+ // eslint-disable-next-line no-new
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Checks that the value is a non empty string value.
+ *
+ * @param value The value to check
+ */
+ static isValidString(value: unknown): boolean {
+ return typeof value === 'string' && value?.trim()?.length >= 1;
+ }
}
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 7998d408e0..c40f7605eb 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,75 @@
# @backstage/cli
+## 0.6.0
+
+### Minor Changes
+
+- 19fe61c27: We have updated the default `eslint` rules in the `@backstage/cli` package.
+
+ ```diff
+ -'@typescript-eslint/no-shadow': 'off',
+ -'@typescript-eslint/no-redeclare': 'off',
+ +'no-shadow': 'off',
+ +'no-redeclare': 'off',
+ +'@typescript-eslint/no-shadow': 'error',
+ +'@typescript-eslint/no-redeclare': 'error',
+ ```
+
+ The rules are documented [here](https://eslint.org/docs/rules/no-shadow) and [here](https://eslint.org/docs/rules/no-redeclare).
+
+ This involved a large number of small changes to the code base. When you compile your own code using the CLI, you may also be
+ affected. We consider these rules important, and the primary recommendation is to try to update your code according to the
+ documentation above. But those that prefer to not enable the rules, or need time to perform the updates, may update their
+ local `.eslintrc.js` file(s) in the repo root and/or in individual plugins as they see fit:
+
+ ```js
+ module.exports = {
+ // ... other declarations
+ rules: {
+ '@typescript-eslint/no-shadow': 'off',
+ '@typescript-eslint/no-redeclare': 'off',
+ },
+ };
+ ```
+
+ Because of the nature of this change, we're unable to provide a grace period for the update :(
+
+### Patch Changes
+
+- 398e1f83e: Update `create-plugin` template to use the new composability API, by switching to exporting a single routable extension component.
+- e9aab60c7: Fixed module resolution of external libraries during backend development. Modules used to be resolved relative to the backend entrypoint, but are now resolved relative to each individual module.
+- a08c4b0b0: Add check for outdated/duplicate packages to yarn start
+- Updated dependencies [062df71db]
+- Updated dependencies [e9aab60c7]
+ - @backstage/config-loader@0.5.1
+
+## 0.5.0
+
+### Minor Changes
+
+- 12a56cdfe: We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures.
+ The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule.
+
+ Every plugin and app has the ability to override these rules if you want to ignore them for now.
+
+ You can reset back to the default behaviour by using the following in your own `.eslint.js`
+
+ ```js
+ rules: {
+ 'no-use-before-define': 'off'
+ }
+ ```
+
+ Because of the nature of this change, we're unable to provide a grace period for the update :(
+
+### Patch Changes
+
+- ef7957be4: Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set.
+- Updated dependencies [ef7957be4]
+- Updated dependencies [ef7957be4]
+- Updated dependencies [ef7957be4]
+ - @backstage/config-loader@0.5.0
+
## 0.4.7
### Patch Changes
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index 7acb538d38..e7fd4cb688 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -38,8 +38,10 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
rules: {
- '@typescript-eslint/no-shadow': 'off',
- '@typescript-eslint/no-redeclare': 'off',
+ 'no-shadow': 'off',
+ 'no-redeclare': 'off',
+ '@typescript-eslint/no-shadow': 'error',
+ '@typescript-eslint/no-redeclare': 'error',
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 044d737d15..37bd7bed94 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -45,8 +45,10 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
rules: {
- '@typescript-eslint/no-shadow': 'off',
- '@typescript-eslint/no-redeclare': 'off',
+ 'no-shadow': 'off',
+ 'no-redeclare': 'off',
+ '@typescript-eslint/no-shadow': 'error',
+ '@typescript-eslint/no-redeclare': 'error',
'no-undef': 'off',
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a98534ecb6..fb33a154a7 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
- "version": "0.4.7",
+ "version": "0.6.0",
"private": false,
"publishConfig": {
"access": "public"
@@ -30,12 +30,12 @@
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.2",
- "@backstage/config-loader": "^0.4.1",
+ "@backstage/config-loader": "^0.5.1",
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@octokit/request": "^5.4.12",
- "@rollup/plugin-commonjs": "^16.0.0",
+ "@rollup/plugin-commonjs": "^17.1.0",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^9.0.0",
"@rollup/plugin-yaml": "^2.1.1",
@@ -113,12 +113,12 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/backend-common": "^0.5.0",
+ "@backstage/backend-common": "^0.5.2",
"@backstage/config": "^0.1.2",
- "@backstage/core": "^0.5.0",
- "@backstage/dev-utils": "^0.1.8",
+ "@backstage/core": "^0.6.0",
+ "@backstage/dev-utils": "^0.1.9",
"@backstage/test-utils": "^0.1.6",
- "@backstage/theme": "^0.2.2",
+ "@backstage/theme": "^0.2.3",
"@types/diff": "^4.0.2",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
@@ -173,7 +173,9 @@
"type": "string",
"visibility": "frontend",
"description": "Tracking ID for Google Analytics",
- "example": "UA-000000-0"
+ "examples": [
+ "UA-000000-0"
+ ]
},
"listen": {
"type": "object",
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index dc165ecb90..b542030109 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -15,12 +15,43 @@
*/
import fs from 'fs-extra';
+import chalk from 'chalk';
+import uniq from 'lodash/uniq';
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadCliConfig } from '../../lib/config';
import { paths } from '../../lib/paths';
+import { Lockfile } from '../../lib/versioning';
+import { includedFilter } from '../versions/lint';
export default async (cmd: Command) => {
+ const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
+ const result = lockfile.analyze({
+ filter: includedFilter,
+ });
+ const problemPackages = [...result.newVersions, ...result.newRanges].map(
+ ({ name }) => name,
+ );
+
+ if (problemPackages.length > 1) {
+ console.log(
+ chalk.yellow(
+ `⚠️ Some of the following packages may be outdated or have duplicate installations:
+
+ ${uniq(problemPackages).join(', ')}
+ `,
+ ),
+ );
+ console.log(
+ chalk.yellow(
+ `⚠️ This can be resolved using the following command:
+
+ yarn backstage-cli versions:check --fix
+ `,
+ ),
+ );
+ }
+
const { name } = await fs.readJson(paths.resolveTarget('package.json'));
const waitForExit = await serveBundle({
entry: 'src/index',
diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts
index 56dcd3f753..930f2c98ca 100644
--- a/packages/cli/src/commands/config/print.ts
+++ b/packages/cli/src/commands/config/print.ts
@@ -63,8 +63,8 @@ function serializeConfigData(
}
const sanitizedConfigs = schema.process(appConfigs, {
- valueTransform: (value, { visibility }) =>
- visibility === 'secret' ? '' : value,
+ valueTransform: (value, context) =>
+ context.visibility === 'secret' ? '' : value,
});
return ConfigReader.fromConfigs(sanitizedConfigs).get();
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index a2005c5580..affbf64922 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -20,6 +20,8 @@ import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath, join as joinPath } from 'path';
+import camelCase from 'lodash/camelCase';
+import upperFirst from 'lodash/upperFirst';
import os from 'os';
import { Command } from 'commander';
import {
@@ -104,16 +106,12 @@ export async function addPluginDependencyToApp(
});
}
-export async function addPluginToApp(
+export async function addPluginImportToApp(
rootDir: string,
- pluginName: string,
+ pluginVar: string,
pluginPackage: string,
) {
- const pluginNameCapitalized = pluginName
- .split('-')
- .map(name => capitalize(name))
- .join('');
- const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`;
+ const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`;
const pluginsFilePath = 'packages/app/src/plugins.ts';
const pluginsFile = resolvePath(rootDir, pluginsFilePath);
@@ -126,6 +124,46 @@ export async function addPluginToApp(
});
}
+export async function addPluginExtensionToApp(
+ pluginId: string,
+ extensionName: string,
+ pluginPackage: string,
+) {
+ const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx');
+ if (!(await fs.pathExists(pluginsFilePath))) {
+ return;
+ }
+
+ await Task.forItem('processing', pluginsFilePath, async () => {
+ 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) {
+ revLines.splice(
+ lastImportIndex,
+ 0,
+ `import { ${extensionName} } from '${pluginPackage}';`,
+ );
+ const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
+ revLines.splice(
+ lastRouteIndex + 1,
+ 0,
+ `${indentation}}/>`,
+ );
+
+ const newContent = revLines.reverse().join('\n');
+ await fs.writeFile(pluginsFilePath, newContent, 'utf8');
+ }
+ });
+}
+
async function cleanUp(tempDir: string) {
await Task.forItem('remove', 'temporary directory', async () => {
await fs.remove(tempDir);
@@ -223,6 +261,8 @@ export default async (cmd: Command) => {
const name = cmd.scope
? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}`
: `plugin-${pluginId}`;
+ const pluginVar = `${camelCase(answers.id)}Plugin`;
+ const extensionName = `${upperFirst(camelCase(answers.id))}Page`;
const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
const privatePackage = cmd.private === false ? false : true;
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
@@ -259,7 +299,9 @@ export default async (cmd: Command) => {
tempDir,
{
...answers,
+ pluginVar,
pluginVersion,
+ extensionName,
name,
privatePackage,
npmRegistry,
@@ -278,7 +320,8 @@ export default async (cmd: Command) => {
await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
Task.section('Import plugin in app');
- await addPluginToApp(paths.targetRoot, pluginId, name);
+ await addPluginImportToApp(paths.targetRoot, pluginVar, name);
+ await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (ownerIds && ownerIds.length) {
diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts
index 416d67869b..fb96250567 100644
--- a/packages/cli/src/commands/plugin/diff.ts
+++ b/packages/cli/src/commands/plugin/diff.ts
@@ -39,6 +39,11 @@ const fileHandlers = [
patterns: ['package.json'],
handler: handlers.packageJson,
},
+ {
+ // Not all plugins have routes
+ patterns: ['src/routes.ts'],
+ handler: handlers.skip,
+ },
{
// make sure files in 1st level of src/ and dev/ exist
patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/],
diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts
index 987d4f6f31..31803d4827 100644
--- a/packages/cli/src/commands/versions/bump.test.ts
+++ b/packages/cli/src/commands/versions/bump.test.ts
@@ -108,7 +108,7 @@ describe('bump', () => {
paths.targetDir = '/';
jest
.spyOn(paths, 'resolveTargetRoot')
- .mockImplementation((...paths) => resolvePath('/', ...paths));
+ .mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
@@ -204,7 +204,7 @@ describe('bump', () => {
paths.targetDir = '/';
jest
.spyOn(paths, 'resolveTargetRoot')
- .mockImplementation((...paths) => resolvePath('/', ...paths));
+ .mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async () => '');
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
index 0478337c61..fed8b39a19 100644
--- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
+++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
@@ -41,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin {
callback: () => void,
) => {
const pkg = this.packages.find(
- pkg => data.path && isChildPath(pkg.location, data.path),
+ pkge => data.path && isChildPath(pkge.location, data.path),
);
if (!pkg) {
callback();
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index c53a8329f5..6fbcc65c9a 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -219,7 +219,7 @@ export async function createBackendConfig(
}
: {}),
externals: [
- nodeExternals({
+ nodeExternalsWithResolve({
modulesDir: paths.rootNodeModules,
additionalModuleDirs: moduleDirs,
allowlist: ['webpack/hot/poll?100', ...localPackageNames],
@@ -296,3 +296,32 @@ export async function createBackendConfig(
],
};
}
+
+// This makes the module resolution happen from the context of each non-external module, rather
+// than the main entrypoint. This fixes a bug where dependencies would be resolved from the backend
+// package rather than each individual backend package and plugin.
+//
+// TODO(Rugvip): Feature suggestion/contribute this to webpack-externals
+function nodeExternalsWithResolve(
+ options: Parameters[0],
+) {
+ let currentContext: string;
+ const externals = nodeExternals({
+ ...options,
+ importType(request) {
+ const resolved = require.resolve(request, {
+ paths: [currentContext],
+ });
+ return `commonjs ${resolved}`;
+ },
+ });
+
+ return (
+ context: string,
+ request: string,
+ callback: webpack.ExternalsFunctionCallback,
+ ) => {
+ currentContext = context;
+ return externals(context, request, callback);
+ };
+}
diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts
index f38416ca4e..c8d48a7199 100644
--- a/packages/cli/src/lib/bundler/paths.ts
+++ b/packages/cli/src/lib/bundler/paths.ts
@@ -42,14 +42,14 @@ export type BundlingPathsOptions = {
export function resolveBundlingPaths(options: BundlingPathsOptions) {
const { entry } = options;
- const resolveTargetModule = (path: string) => {
+ const resolveTargetModule = (pathString: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
- const filePath = paths.resolveTarget(`${path}.${ext}`);
+ const filePath = paths.resolveTarget(`${pathString}.${ext}`);
if (fs.pathExistsSync(filePath)) {
return filePath;
}
}
- return paths.resolveTarget(`${path}.js`);
+ return paths.resolveTarget(`${pathString}.js`);
};
let targetPublic = undefined;
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx b/packages/cli/templates/default-plugin/dev/index.tsx
deleted file mode 100644
index 6fce113093..0000000000
--- a/packages/cli/templates/default-plugin/dev/index.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createDevApp } from '@backstage/dev-utils';
-import { plugin } from '../src/plugin';
-
-createDevApp().registerPlugin(plugin).render();
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx.hbs b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
new file mode 100644
index 0000000000..ade00a1613
--- /dev/null
+++ b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
@@ -0,0 +1,11 @@
+import React from 'react';
+import { createDevApp } from '@backstage/dev-utils';
+import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin';
+
+createDevApp()
+ .registerPlugin({{ pluginVar }})
+ .addPage({
+ element: <{{ extensionName }} />,
+ title: 'Root Page',
+ })
+ .render();
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
index e805900f36..e6eab17493 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
@@ -1,13 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';
-import ExampleComponent from './ExampleComponent';
+import { ExampleComponent } from './ExampleComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
-
describe('ExampleComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
@@ -15,15 +14,17 @@ describe('ExampleComponent', () => {
// setup mock response
beforeEach(() => {
- server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))))
- })
+ server.use(
+ rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
+ );
+ });
it('should render', () => {
const rendered = render(
,
- );
- expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
+ );
+ expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
});
});
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
index 5f90f2de1e..dcecebdf46 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
@@ -9,9 +9,9 @@ import {
HeaderLabel,
SupportButton,
} from '@backstage/core';
-import ExampleFetchComponent from '../ExampleFetchComponent';
+import { ExampleFetchComponent } from '../ExampleFetchComponent';
-const ExampleComponent = () => (
+export const ExampleComponent = () => (
@@ -36,5 +36,3 @@ const ExampleComponent = () => (
);
-
-export default ExampleComponent;
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts
index 520a3bf553..8b8437521b 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts
@@ -1 +1 @@
-export { default } from './ExampleComponent';
+export { ExampleComponent } from './ExampleComponent';
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
index 81e1b4be09..fa1e289d8e 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
-import ExampleFetchComponent from './ExampleFetchComponent';
+import { ExampleFetchComponent } from './ExampleFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
@@ -9,11 +9,15 @@ describe('ExampleFetchComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
msw.setupDefaultHandlers(server);
-
+
// setup mock response
beforeEach(() => {
- server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({}))))
- })
+ server.use(
+ rest.get('https://randomuser.me/*', (_, res, ctx) =>
+ res(ctx.status(200), ctx.delay(2000), ctx.json({})),
+ ),
+ );
+ });
it('should render', async () => {
const rendered = render();
expect(await rendered.findByTestId('progress')).toBeInTheDocument();
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
index 8cc5ed2ab7..20dd6d1f57 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
@@ -48,7 +48,7 @@ export const DenseTable = ({ users }: DenseTableProps) => {
{ title: 'Nationality', field: 'nationality' },
];
- const data = users.map((user) => {
+ const data = users.map(user => {
return {
avatar: (
{
);
};
-const ExampleFetchComponent = () => {
+export const ExampleFetchComponent = () => {
const { value, loading, error } = useAsync(async (): Promise => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
@@ -88,5 +88,3 @@ const ExampleFetchComponent = () => {
return ;
};
-
-export default ExampleFetchComponent;
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts
index 3e53453948..41a43e84f1 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts
+++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts
@@ -1 +1 @@
-export { default } from './ExampleFetchComponent';
+export { ExampleFetchComponent } from './ExampleFetchComponent';
diff --git a/packages/cli/templates/default-plugin/src/index.ts b/packages/cli/templates/default-plugin/src/index.ts
deleted file mode 100644
index 99edba26c3..0000000000
--- a/packages/cli/templates/default-plugin/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { plugin } from './plugin';
diff --git a/packages/cli/templates/default-plugin/src/index.ts.hbs b/packages/cli/templates/default-plugin/src/index.ts.hbs
new file mode 100644
index 0000000000..be4881efaf
--- /dev/null
+++ b/packages/cli/templates/default-plugin/src/index.ts.hbs
@@ -0,0 +1 @@
+export { {{ pluginVar }}, {{ extensionName }} } from './plugin';
diff --git a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs
index c2be0a5301..9d44a9c497 100644
--- a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs
+++ b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs
@@ -1,7 +1,7 @@
-import { plugin } from './plugin';
+import { {{ pluginVar }} } from './plugin';
describe('{{ id }}', () => {
it('should export plugin', () => {
- expect(plugin).toBeDefined();
+ expect({{ pluginVar }}).toBeDefined();
});
});
diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs
index 1bf4d07cdb..0ae5356fd1 100644
--- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs
+++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs
@@ -1,14 +1,18 @@
-import { createPlugin, createRouteRef } from '@backstage/core';
-import ExampleComponent from './components/ExampleComponent';
+import { createPlugin, createRoutableExtension } from '@backstage/core';
-export const rootRouteRef = createRouteRef({
- path: '/{{ id }}',
- title: '{{ id }}',
-});
+import { rootRouteRef } from './routes';
-export const plugin = createPlugin({
+export const {{ pluginVar }} = createPlugin({
id: '{{ id }}',
- register({ router }) {
- router.addRoute(rootRouteRef, ExampleComponent);
+ routes: {
+ root: rootRouteRef,
},
});
+
+export const {{ extensionName }} = {{ pluginVar }}.provide(
+ createRoutableExtension({
+ component: () =>
+ import('./components/ExampleComponent').then(m => m.ExampleComponent),
+ mountPoint: rootRouteRef,
+ }),
+);
diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs
new file mode 100644
index 0000000000..b2afba074d
--- /dev/null
+++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs
@@ -0,0 +1,5 @@
+import { createRouteRef } from '@backstage/core';
+
+export const rootRouteRef = createRouteRef({
+ title: '{{ id }}',
+});
diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md
index a5e34ad91e..cbc6473286 100644
--- a/packages/config-loader/CHANGELOG.md
+++ b/packages/config-loader/CHANGELOG.md
@@ -1,5 +1,30 @@
# @backstage/config-loader
+## 0.5.1
+
+### Patch Changes
+
+- 062df71db: Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere
+- e9aab60c7: Each piece of the configuration schema is now validated upfront, in order to produce more informative errors.
+
+## 0.5.0
+
+### Minor Changes
+
+- ef7957be4: Removed support for the deprecated `$data` placeholder.
+- ef7957be4: Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files.
+
+### Patch Changes
+
+- ef7957be4: Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `${...}`, which will end up as `${...}`.
+
+ For example:
+
+ ```yaml
+ app:
+ baseUrl: https://${BASE_HOST}
+ ```
+
## 0.4.1
### Patch Changes
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 476c57b014..0b024b2c84 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
- "version": "0.4.1",
+ "version": "0.5.1",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,7 +32,7 @@
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.1",
- "ajv": "^6.12.5",
+ "ajv": "^7.0.3",
"fs-extra": "^9.0.0",
"json-schema": "^0.2.5",
"json-schema-merge-allof": "^0.7.0",
diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts
index 0e53562875..611413931e 100644
--- a/packages/config-loader/src/lib/schema/collect.ts
+++ b/packages/config-loader/src/lib/schema/collect.ts
@@ -118,7 +118,9 @@ export async function collectConfigSchemas(
}
await Promise.all(
- depNames.map(name => processItem({ name, parentPath: pkgPath })),
+ depNames.map(depName =>
+ processItem({ name: depName, parentPath: pkgPath }),
+ ),
);
}
diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts
index f03d7e6d10..e1d8ee5999 100644
--- a/packages/config-loader/src/lib/schema/compile.test.ts
+++ b/packages/config-loader/src/lib/schema/compile.test.ts
@@ -29,11 +29,11 @@ describe('compileConfigSchemas', () => {
},
]);
expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({
- errors: ['Config should be string { type=string } at .a'],
+ errors: ['Config should be string { type=string } at /a'],
visibilityByPath: new Map(),
});
expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
- errors: ['Config should be number { type=number } at .b'],
+ errors: ['Config should be number { type=number } at /b'],
visibilityByPath: new Map(),
});
});
@@ -80,10 +80,10 @@ describe('compileConfigSchemas', () => {
).toEqual({
visibilityByPath: new Map(
Object.entries({
- '.a': 'frontend',
- '.b': 'secret',
- '.d': 'secret',
- '.d.0': 'frontend',
+ '/a': 'frontend',
+ '/b': 'secret',
+ '/d': 'secret',
+ '/d/0': 'frontend',
}),
),
});
diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts
index f01a4f640a..e340d775a1 100644
--- a/packages/config-loader/src/lib/schema/compile.ts
+++ b/packages/config-loader/src/lib/schema/compile.ts
@@ -42,23 +42,25 @@ export function compileConfigSchemas(
const ajv = new Ajv({
allErrors: true,
+ allowUnionTypes: true,
schemas: {
'https://backstage.io/schema/config-v1': true,
},
- }).addKeyword('visibility', {
+ }).addKeyword({
+ keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
- return (_data, dataPath) => {
- if (!dataPath) {
+ return (_data, context) => {
+ if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
- const normalizedPath = dataPath.replace(
+ const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
- (_, segment) => `.${segment}`,
+ (_, segment) => `/${segment}`,
);
visibilityByPath.set(normalizedPath, visibility);
}
@@ -67,6 +69,14 @@ export function compileConfigSchemas(
},
});
+ for (const schema of schemas) {
+ try {
+ ajv.compile(schema.value);
+ } catch (error) {
+ throw new Error(`Schema at ${schema.path} is invalid, ${error}`);
+ }
+ }
+
const merged = mergeAllOf(
{ allOf: schemas.map(_ => _.value) },
{
diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts
index f6926e3560..feb31ebab0 100644
--- a/packages/config-loader/src/lib/schema/filtering.test.ts
+++ b/packages/config-loader/src/lib/schema/filtering.test.ts
@@ -40,24 +40,24 @@ const data = {
const visibility = new Map(
Object.entries({
- '.arr.0': 'frontend',
- '.arr.1': 'backend',
- '.arr.2': 'secret',
- '.obj.f': 'frontend',
- '.obj.b': 'backend',
- '.obj.b.s': 'secret',
- '.objArr.0.f': 'frontend',
- '.objArr.0.b': 'backend',
- '.objArr.0.s': 'secret',
- '.objArr.1.f': 'frontend',
- '.objArr.1.b': 'backend',
- '.objArr.1.s': 'secret',
- '.arrF': 'frontend',
- '.arrB': 'backend',
- '.arrS': 'secret',
- '.objF': 'frontend',
- '.objB': 'backend',
- '.objS': 'secret',
+ '/arr/0': 'frontend',
+ '/arr/1': 'backend',
+ '/arr/2': 'secret',
+ '/obj/f': 'frontend',
+ '/obj/b': 'backend',
+ '/obj/b/s': 'secret',
+ '/objArr/0/f': 'frontend',
+ '/objArr/0/b': 'backend',
+ '/objArr/0/s': 'secret',
+ '/objArr/1/f': 'frontend',
+ '/objArr/1/b': 'backend',
+ '/objArr/1/s': 'secret',
+ '/arrF': 'frontend',
+ '/arrB': 'backend',
+ '/arrS': 'secret',
+ '/objF': 'frontend',
+ '/objB': 'backend',
+ '/objS': 'secret',
}),
);
diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts
index 10a97f9a7f..93e899c9d8 100644
--- a/packages/config-loader/src/lib/schema/filtering.ts
+++ b/packages/config-loader/src/lib/schema/filtering.ts
@@ -49,7 +49,7 @@ export function filterByVisibility(
const arr = new Array();
for (const [index, value] of jsonVal.entries()) {
- const out = transform(value, `${path}.${index}`);
+ const out = transform(value, `${path}/${index}`);
if (out !== undefined) {
arr.push(out);
}
@@ -68,7 +68,7 @@ export function filterByVisibility(
if (value === undefined) {
continue;
}
- const out = transform(value, `${path}.${key}`);
+ const out = transform(value, `${path}/${key}`);
if (out !== undefined) {
outObj[key] = out;
hasOutput = true;
diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts
index baf63525bb..7d9f7cc803 100644
--- a/packages/config-loader/src/lib/schema/load.test.ts
+++ b/packages/config-loader/src/lib/schema/load.test.ts
@@ -85,7 +85,7 @@ describe('loadConfigSchema', () => {
expect(() =>
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
).toThrow(
- 'Config validation failed, Config should be string { type=string } at .key1',
+ 'Config validation failed, Config should be string { type=string } at /key1',
);
await expect(
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
index 85f9d3a921..927eca3490 100644
--- a/packages/core-api/package.json
+++ b/packages/core-api/package.json
@@ -30,7 +30,7 @@
},
"dependencies": {
"@backstage/config": "^0.1.2",
- "@backstage/theme": "^0.2.2",
+ "@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
@@ -42,7 +42,7 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/cli": "^0.4.5",
+ "@backstage/cli": "^0.6.0",
"@backstage/test-utils": "^0.1.6",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts
index 517bf82ae7..e7764fcbaa 100644
--- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts
+++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts
@@ -13,12 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- AuthProvider,
- ProfileInfo,
- BackstageIdentity,
- DiscoveryApi,
-} from '../../apis/definitions';
+import { AuthProvider, DiscoveryApi } from '../../apis/definitions';
import { showLoginPopup } from '../loginPopup';
type Options = {
@@ -26,13 +21,6 @@ type Options = {
environment?: string;
provider: AuthProvider & { id: string };
};
-
-export type DirectAuthResponse = {
- userId: string;
- profile: ProfileInfo;
- backstageIdentity: BackstageIdentity;
-};
-
export class DirectAuthConnector {
private readonly discoveryApi: DiscoveryApi;
private readonly environment: string | undefined;
diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts
index 2e14447882..716c4f9651 100644
--- a/packages/core-api/src/lib/loginPopup.ts
+++ b/packages/core-api/src/lib/loginPopup.ts
@@ -82,7 +82,9 @@ export function showLoginPopup(options: LoginPopupOptions): Promise {
let targetOrigin = '';
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
- reject(new Error('Failed to open auth popup.'));
+ const error = new Error('Failed to open auth popup.');
+ error.name = 'PopupRejectedError';
+ reject(error);
return;
}
@@ -120,7 +122,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise {
const intervalId = setInterval(() => {
if (popup.closed) {
const errMessage = `Login failed, ${
- targetOrigin !== window.location.origin
+ targetOrigin && targetOrigin !== window.location.origin
? `Incorrect app origin, expected ${targetOrigin}`
: 'popup was closed'
}`;
diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx
index a6783964a7..cefb347290 100644
--- a/packages/core-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-api/src/routing/FlatRoutes.tsx
@@ -25,8 +25,8 @@ type RouteObject = {
// Similar to the same function from react-router, this collects routes from the
// children, but only the first level of routes
-function createRoutesFromChildren(children: ReactNode): RouteObject[] {
- return Children.toArray(children)
+function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] {
+ return Children.toArray(childrenNode)
.flatMap(child => {
if (!isValidElement(child)) {
return [];
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 0fea08e539..60a6e43654 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,35 @@
# @backstage/core
+## 0.6.0
+
+### Minor Changes
+
+- 21e624ba9: Closes #3556
+ The scroll bar of collapsed sidebar is now hidden without full screen.
+
+ 
+
+### Patch Changes
+
+- 12ece98cd: Add className to the SidebarItem
+- d82246867: Update `WarningPanel` component to use accordion-style expansion
+- 5fa3bdb55: Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a
+ `ItemCard` with and without tags is equal.
+- da9f53c60: Add a `prop` union for `SignInPage` that allows it to be used for just a single provider, with inline errors, and optionally with automatic sign-in.
+- 32c95605f: Fix check that determines whether popup was closed or the messaging was misconfigured.
+- 54c7d02f7: Introduce `TabbedLayout` for creating tabs that are routed.
+
+ ```typescript
+
+
+
This is rendered under /example/anything-here route
+
+
+ ```
+
+- Updated dependencies [c810082ae]
+ - @backstage/theme@0.2.3
+
## 0.5.0
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index a937502247..41dbc8ef14 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
- "version": "0.5.0",
+ "version": "0.6.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,10 +31,11 @@
"dependencies": {
"@backstage/config": "^0.1.2",
"@backstage/core-api": "^0.2.8",
- "@backstage/theme": "^0.2.2",
+ "@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+ "@testing-library/react-hooks": "^3.4.2",
"@types/dagre": "^0.7.44",
"@types/react": "^16.9",
"@types/react-sparklines": "^1.7.0",
@@ -65,7 +66,7 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/cli": "^0.4.7",
+ "@backstage/cli": "^0.6.0",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
diff --git a/packages/core/src/components/ProgressBars/Gauge.stories.tsx b/packages/core/src/components/ProgressBars/Gauge.stories.tsx
index 7882714529..ab9c263f05 100644
--- a/packages/core/src/components/ProgressBars/Gauge.stories.tsx
+++ b/packages/core/src/components/ProgressBars/Gauge.stories.tsx
@@ -20,7 +20,7 @@ import { Gauge } from './Gauge';
const containerStyle = { width: 300 };
export default {
- title: 'Feedback/Gauge',
+ title: 'Data Display/Gauge',
component: Gauge,
};
diff --git a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
index a745d210b4..fa3c7c00f0 100644
--- a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
+++ b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
@@ -20,7 +20,7 @@ import { LinearGauge } from './LinearGauge';
const containerStyle = { width: 300 };
export default {
- title: 'Feedback/LinearGauge',
+ title: 'Data Display/LinearGauge',
component: LinearGauge,
};
diff --git a/packages/core/src/components/Status/Status.stories.tsx b/packages/core/src/components/Status/Status.stories.tsx
index 560d07ad87..205645e1ec 100644
--- a/packages/core/src/components/Status/Status.stories.tsx
+++ b/packages/core/src/components/Status/Status.stories.tsx
@@ -27,7 +27,7 @@ import { Table } from '../Table';
import { InfoCard } from '../../layout/InfoCard';
export default {
- title: 'Feedback/Status',
+ title: 'Data Display/Status',
component: StatusOK,
};
diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
similarity index 92%
rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
rename to packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
index 065265cea4..549aa55eff 100644
--- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
+++ b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
@@ -13,12 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React from 'react';
-import { TabbedLayout } from './TabbedLayout';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
+import React from 'react';
import { act } from 'react-dom/test-utils';
-import { Routes, Route } from 'react-router';
+import { Route, Routes } from 'react-router';
+import { RoutedTabs } from './RoutedTabs';
const testRoute1 = {
path: '',
@@ -31,10 +31,10 @@ const testRoute2 = {
children:
tabbed-test-content-2
,
};
-describe('TabbedLayout', () => {
+describe('RoutedTabs', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
@@ -46,7 +46,7 @@ describe('TabbedLayout', () => {
}
+ element={}
/>
,
);
@@ -70,7 +70,7 @@ describe('TabbedLayout', () => {
{
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
{ routeEntries: ['/some-other-path'] },
);
@@ -135,7 +135,7 @@ describe('TabbedLayout', () => {
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
{ routeEntries: ['/non-existing-path'] },
);
diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx
similarity index 84%
rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx
rename to packages/core/src/components/TabbedLayout/RoutedTabs.tsx
index a2209ba492..b17c8afb3b 100644
--- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx
+++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import React, { useMemo } from 'react';
-import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router';
-import { HeaderTabs, Content as LayoutContent } from '@backstage/core';
import { Helmet } from 'react-helmet';
+import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router';
+import { Content, HeaderTabs } from '../../layout';
import { SubRoute } from './types';
export function useSelectedSubRoute(
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(
};
}
-export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
+export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => {
const navigate = useNavigate();
const { index, route, element } = useSelectedSubRoute(routes);
const headerTabs = useMemo(
@@ -52,12 +52,12 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
[routes],
);
- const onTabChange = (index: number) =>
+ const onTabChange = (tabIndex: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
- navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, ''));
+ navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, ''));
return (
<>
@@ -66,10 +66,10 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
selectedIndex={index}
onChange={onTabChange}
/>
-
+
{element}
-
+
>
);
};
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx
new file mode 100644
index 0000000000..bf6175a9a2
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { PropsWithChildren } from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+export default {
+ title: 'Navigation/TabbedLayout',
+ component: TabbedLayout,
+};
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+
+ {children}>} />
+
+
+);
+
+export const Default = () => (
+
+
+
+
tabbed-test-content
+
+
+
tabbed-test-content-2
+
+
+
+);
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx
new file mode 100644
index 0000000000..77230ab6cd
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+describe('TabbedLayout', () => {
+ it('renders simplest case', async () => {
+ const { getByText } = await renderInTestApp(
+
+
+
tabbed-test-content
+
+ ,
+ );
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(getByText('tabbed-test-content')).toBeInTheDocument();
+ });
+
+ it('throws if any other component is a child of TabbedLayout', async () => {
+ const { error } = await withLogCollector(async () => {
+ await expect(
+ renderInTestApp(
+
+
+
tabbed-test-content
+
+
This will cause app to throw
+ ,
+ ),
+ ).rejects.toThrow(/Child of TabbedLayout must be an TabbedLayout.Route/);
+ });
+
+ expect(error).toEqual([
+ expect.stringMatching(
+ /Child of TabbedLayout must be an TabbedLayout.Route/,
+ ),
+ expect.stringMatching(
+ /The above error occurred in the component/,
+ ),
+ ]);
+ });
+
+ it('navigates when user clicks different tab', async () => {
+ const { getByText, queryByText, queryAllByRole } = await renderInTestApp(
+
+
+
+
tabbed-test-content
+
+
+
tabbed-test-content-2
+
+
+ }
+ />
+ ,
+ );
+
+ const secondTab = queryAllByRole('tab')[1];
+ act(() => {
+ fireEvent.click(secondTab);
+ });
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx
new file mode 100644
index 0000000000..f181ee5980
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { attachComponentData } from '@backstage/core-api';
+import React, {
+ Children,
+ Fragment,
+ isValidElement,
+ PropsWithChildren,
+ ReactNode,
+} from 'react';
+import { RoutedTabs } from './RoutedTabs';
+
+type SubRoute = {
+ path: string;
+ title: string;
+ children: JSX.Element;
+};
+
+const Route: (props: SubRoute) => null = () => null;
+
+// This causes all mount points that are discovered within this route to use the path of the route itself
+attachComponentData(Route, 'core.gatherMountPoints', true);
+
+export function createSubRoutesFromChildren(
+ childrenProps: ReactNode,
+): SubRoute[] {
+ // Directly comparing child.type with Route will not work with in
+ // combination with react-hot-loader in storybook
+ // https://github.com/gaearon/react-hot-loader/issues/304
+ const routeType = (
+
+
+
+ ).type;
+
+ return Children.toArray(childrenProps).flatMap(child => {
+ if (!isValidElement(child)) {
+ return [];
+ }
+
+ if (child.type === Fragment) {
+ return createSubRoutesFromChildren(child.props.children);
+ }
+
+ if (child.type !== routeType) {
+ throw new Error('Child of TabbedLayout must be an TabbedLayout.Route');
+ }
+
+ const { path, title, children } = child.props;
+ return [{ path, title, children }];
+ });
+}
+
+/**
+ * TabbedLayout is a compound component, which allows you to define a layout for
+ * pages using a sub-navigation mechanism.
+ *
+ * Consists of two parts: TabbedLayout and TabbedLayout.Route
+ *
+ * @example
+ * ```jsx
+ *
+ *
+ *
This is rendered under /example/anything-here route
+ *
+ *
+ * ```
+ */
+export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => {
+ const routes = createSubRoutesFromChildren(children);
+
+ return ;
+};
+
+TabbedLayout.Route = Route;
diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts
new file mode 100644
index 0000000000..744b56959e
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { TabbedLayout } from './TabbedLayout';
diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts
similarity index 100%
rename from plugins/catalog/src/components/EntityLayout/types.ts
rename to packages/core/src/components/TabbedLayout/types.ts
diff --git a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
index 5098f0aa31..ef99a0fce4 100644
--- a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
+++ b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
@@ -16,7 +16,7 @@
import React from 'react';
import { WarningPanel } from './WarningPanel';
-import { Link, Button } from '@material-ui/core';
+import { Button, Link, Typography } from '@material-ui/core';
export default {
title: 'Feedback/Warning Panel',
@@ -25,11 +25,11 @@ export default {
export const Default = () => (
- This example entity is missing something. If this is unexpected, please
- make sure you have set up everything correctly by following{' '}
+ This example entity is missing an annotation. If this is unexpected,
+ please make sure you have set up everything correctly by following{' '}
this guide.
>
}
@@ -37,9 +37,36 @@ export const Default = () => (
);
export const Children = () => (
-
-
+
+
+ Supports custom children - for example these text elements. This can be
+ used to hide/expose stack traces for warnings, like this example:
+
+ SyntaxError: Error transforming
+ /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx:
+ Unexpected token (42:16) at unexpected
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15)
+ at tsParseMaybeAssignWithJSX
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1399:22)
+ at tsParseMaybeAssign
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1373:12)
+ at parseMaybeAssign
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:118:43)
+ at parseExprListItem
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:969:5)
+
+
);
+
+export const FullExample = () => (
+
+ HTTP 500 Bad Gateway response from
+ https://usefulservice.mycompany.com/api/entity?44433
+
+);
+
+export const TitleOnly = () => ;
diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
index 07a25d34c8..38ba4bff9b 100644
--- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
+++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx
@@ -15,23 +15,53 @@
*/
import React from 'react';
+import { fireEvent, screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
+import { Typography } from '@material-ui/core';
import { WarningPanel } from './WarningPanel';
-const minProps = { title: 'Mock title', message: 'Some more info' };
+const propsTitle = { title: 'Mock title' };
+const propsTitleMessage = { title: 'Mock title', message: 'Some more info' };
+const propsMessage = { message: 'Some more info' };
describe('', () => {
it('renders without exploding', async () => {
- const { getByText } = await renderInTestApp();
- expect(getByText('Mock title')).toBeInTheDocument();
+ await renderInTestApp();
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
});
- it('renders message and children', async () => {
- const { getByText } = await renderInTestApp(
- children,
+ it('renders title', async () => {
+ await renderInTestApp();
+ const expandIcon = await screen.getByText('Warning: Mock title');
+ fireEvent.click(expandIcon);
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ });
+
+ it('renders title and children', async () => {
+ await renderInTestApp(
+
+ Java stacktrace
+ ,
);
- expect(getByText('Some more info')).toBeInTheDocument();
- expect(getByText('children')).toBeInTheDocument();
+ expect(screen.getByText('Java stacktrace')).toBeInTheDocument();
+ });
+
+ it('renders message', async () => {
+ await renderInTestApp();
+ expect(screen.getByText('Warning')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ });
+
+ it('renders title, message, and children', async () => {
+ await renderInTestApp(
+
+ Java stacktrace
+ ,
+ );
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ expect(screen.getByText('Java stacktrace')).toBeInTheDocument();
});
});
diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx
index ae4d2bff02..e82c49c49a 100644
--- a/packages/core/src/components/WarningPanel/WarningPanel.tsx
+++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx
@@ -15,8 +15,16 @@
*/
import { BackstageTheme } from '@backstage/theme';
-import { makeStyles, Typography } from '@material-ui/core';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Grid,
+ makeStyles,
+ Typography,
+} from '@material-ui/core';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import React from 'react';
const useErrorOutlineStyles = makeStyles(theme => ({
@@ -29,57 +37,100 @@ const ErrorOutlineStyled = () => {
const classes = useErrorOutlineStyles();
return ;
};
+const ExpandMoreIconStyled = () => {
+ const classes = useErrorOutlineStyles();
+ return ;
+};
const useStyles = makeStyles(theme => ({
- message: {
- display: 'flex',
- flexDirection: 'column',
- padding: theme.spacing(1.5),
+ panel: {
backgroundColor: theme.palette.warningBackground,
color: theme.palette.warningText,
verticalAlign: 'middle',
},
- header: {
+ summary: {
display: 'flex',
flexDirection: 'row',
- marginBottom: theme.spacing(1),
},
- headerText: {
+ summaryText: {
color: theme.palette.warningText,
+ fontWeight: 'bold',
},
- messageText: {
+ message: {
+ width: '100%',
+ display: 'block',
color: theme.palette.warningText,
+ backgroundColor: theme.palette.warningBackground,
+ },
+ details: {
+ width: '100%',
+ display: 'block',
+ color: theme.palette.textContrast,
+ backgroundColor: theme.palette.background.default,
+ border: `1px solid ${theme.palette.border}`,
+ padding: theme.spacing(2.0),
+ fontFamily: 'sans-serif',
},
}));
-/**
- * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel
- * only shows the warning message to the user
- */
-
type Props = {
- message?: React.ReactNode;
title?: string;
+ severity?: 'warning' | 'error' | 'info';
+ message?: React.ReactNode;
children?: React.ReactNode;
};
+const capitalize = (s: string) => {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+};
+
+/**
+ * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel
+ * only shows the warning message to the user.
+ *
+ * @param {string} [severity=warning] Ability to change the severity of the alert. Not fully implemented. (error, warning, info)
+ * @param {string} [title] A title for the warning. If not supplied, "Warning" will be used.
+ * @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error.
+ * @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting.
+ * Will be available inside an unfolded accordion.
+ */
export const WarningPanel = (props: Props) => {
const classes = useStyles(props);
- const { title, message, children } = props;
+ const { severity, title, message, children } = props;
+
+ // If no severity or title provided, the heading will read simply "Warning"
+ const subTitle =
+ (severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : '');
+
return (
-